diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index a42c762..3cfd71e 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -99,10 +99,56 @@ jobs: run: | export AGENT_WORKFLOW_CONTAINER_IMAGE="$(cat "dist/worker-linux-$ARCH.iid")" go test ./pkg/clirunner -run '^TestContainer(ExecutionAndCleanup|RuntimeRemoval)$' -count=1 + workflow-ui: + name: Workflow UI (${{ matrix.browser }}) + runs-on: ubuntu-24.04 + strategy: + fail-fast: false + matrix: + browser: [chromium, firefox, webkit] + container: + # Keep this version aligned with @playwright/test in ui/package-lock.json. + image: mcr.microsoft.com/playwright:v1.64.1-noble + options: --user 1001 --init --ipc=host + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + persist-credentials: false + fetch-depth: 0 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: '24' + cache: npm + cache-dependency-path: ui/package-lock.json + - run: npm ci --prefix ui --ignore-scripts + - name: Check flow checker and UF documentation synchronization + run: | + git config --global --add safe.directory "$GITHUB_WORKSPACE" + node --test ui/scripts/flows-check.test.mjs + node ui/scripts/flows-check.mjs --ci + - name: Check types and unit tests + run: | + npm run check --prefix ui + npm test --prefix ui + - name: Build and test packed consumers + run: | + npm run build --prefix ui + npm run test:consumers --prefix ui + - name: Run browser tests + run: npm run test:browser --prefix ui -- --project=${{ matrix.browser }} + - name: Upload UI browser failure reports + if: failure() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: workflow-ui-${{ matrix.browser }}-failure + path: | + ui/playwright-report/ + ui/test-results/ + if-no-files-found: ignore required: name: Required if: always() - needs: [native, workflow-validation, filesystem-conformance, container-conformance] + needs: [native, workflow-validation, workflow-ui, filesystem-conformance, container-conformance] runs-on: ubuntu-24.04 steps: - env: diff --git a/.gitignore b/.gitignore index 4ede119..f6a9808 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,10 @@ node_modules/ /dist/ __pycache__/ +/ui/dist/ +/ui/.consumer-artifacts/ +/ui/consumers/*/dist/ +/ui/test-results/ +/ui/playwright-report/ +/ui/.verification-tmp/ +/ui/public/packed/ diff --git a/AGENTS.md b/AGENTS.md index 8deca75..bf2d1c3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -14,3 +14,20 @@ filesystem. Preserve unrelated workspace files. `make test` and `make check` are deterministic. The pinned-SDK fake-gateway test uses a local fake API and requires its locked Node dependencies. Real provider and publication evidence remains separate from deterministic validation. + +# UI flow verification on every PR + +Before declaring any PR ready, run `make ui-pr-check BASE=` +(for example `BASE=origin/main`). Run it again after subsequent relevant edits. +This command checks documentation/test synchronization and runs affected user +flows in Chromium without package builds or provider access. Even unrelated PRs +must run it; they validate structure without starting a browser. Do not skip a +missing baseline: fetch the target or supply an available target explicitly. + +Each `UF-NN` section in `docs/UI_USER_FLOWS.md` owns +`ui/test/browser/flows/uf-NN.spec.ts` and links to it. Change both when changing +either substantive prose or test behavior. Shared test helper/fixture changes +require meaningful Coverage notes in every affected flow. If behavior stays the +same, explain revised assertions or recovery scenarios in the Coverage note; +comments, formatting, link edits, timestamps, and checksums do not acknowledge +a change. Preserve stable UF IDs. See `ui/README.md` for setup and repairs. diff --git a/Makefile b/Makefile index 5b3d1d3..8dc29aa 100644 --- a/Makefile +++ b/Makefile @@ -16,3 +16,25 @@ release-check: release-tooling-test python3 scripts/validate-release.py "$(TAG)" release-tooling-test: python3 -m unittest discover -s scripts -p 'test_*.py' -v + +.PHONY: ui-install ui-check ui-test ui-build ui-browser-test +ui-install: + npm ci --prefix ui --ignore-scripts +ui-check: + npm run check --prefix ui +ui-test: + npm test --prefix ui +ui-build: + npm run build --prefix ui +ui-browser-test: ui-build + npm run test:consumers --prefix ui + npm run test:browser --prefix ui + +.PHONY: ui-flows-check ui-pr-check ui-flow-checker-test +BASE ?= origin/main +ui-flows-check: + node ui/scripts/flows-check.mjs --base="$(BASE)" +ui-flow-checker-test: + node --test ui/scripts/flows-check.test.mjs +ui-pr-check: ui-flow-checker-test + node ui/scripts/flows-check.mjs --base="$(BASE)" --run diff --git a/README.md b/README.md index 1f7cca7..412676a 100644 --- a/README.md +++ b/README.md @@ -552,3 +552,8 @@ standalone Go implementations. Stable release preparation, signed evidence, verification and remaining certification gates: [release operations](docs/RELEASE_PIPELINE.md). + +The independent [workflow viewer and editor](ui/README.md) provides React components, +plain JavaScript mounts, and a standalone browser bundle for authoring workflow YAML. +Run `make ui-install ui-check ui-test ui-browser-test` for deterministic UI validation, +or `npm run dev --prefix ui` to explore the repository examples locally. diff --git a/docs/UI_USER_FLOWS.md b/docs/UI_USER_FLOWS.md new file mode 100644 index 0000000..f42c80d --- /dev/null +++ b/docs/UI_USER_FLOWS.md @@ -0,0 +1,689 @@ +# UI user flows + +These flows describe the current workflow editor and viewer from my point of +view as a user. They cover the UI in [`ui/`](../ui/README.md), including the +local playground. Each flow lists where I start, what I do, and what I see. + +The host application supplies the document and may provide saving, validation, +agent choices, reusable workflows, and custom action forms. The embedded UI +authors workflow definitions; running workflows, monitoring runs, logging in, +and publishing pull requests are outside this UI. Adding a `github/pr` step +only changes the definition. + +In keyboard shortcuts, **Mod** means Command on macOS and Ctrl elsewhere. +Property text fields apply when I leave them; Enter also applies single-line +fields. Escape discards an uncommitted field edit. + +## Opening and navigating + +### UF-01 — Explore an existing workflow + +**UI tests**: [UI tests](../ui/test/browser/flows/uf-01.spec.ts) + +- **Coverage:** Selection reveals long YAML definitions without moving focus, in both editor and viewer. + +- **Starting point:** The host opens a workflow in the editor. +- **My flow:** I read the workflow name → inspect the job cards and dependency + arrows → expand a job to see its steps → select a job or step. +- **What I see:** The inspector shows its properties. In Split or YAML mode, + the matching YAML definition is selected and scrolled into view without + taking keyboard focus away from the control I selected. + +### UF-02 — Choose how I view the workflow + +**UI tests**: [UI tests](../ui/test/browser/flows/uf-02.spec.ts) + +- **Coverage:** View switches preserve source and reveal the selected job when YAML opens. + +- **Starting point:** A workflow is open. +- **My flow:** I choose **Visual**, **YAML**, or **Split** in the toolbar. +- **What I see:** I get the graph, the source editor, or both. My document stays + the same. If I select an item in Visual mode and then open YAML, its source + definition is revealed. + +### UF-03 — Find a job or step + +**UI tests**: [UI tests](../ui/test/browser/flows/uf-03.spec.ts) + +- **Coverage:** Canvas dimming, palette filtering, and step inspection use the visible search controls. + +- **Starting point:** A workflow has several jobs or steps. +- **My flow:** I type in **Search workflow** to find matching content, or use + **Search palette** to narrow the job navigation → select the job → select + its step on the card or in the inspector. +- **What I see:** Canvas search dims jobs that do not match while preserving + dependency context. Palette search filters its entries. Selecting a job from + the palette also brings it into view. + +### UF-04 — Arrange the graph for easier reading + +**UI tests**: [UI tests](../ui/test/browser/flows/uf-04.spec.ts) + +- **Coverage:** Collapse, zoom, fit, auto layout, and dragging preserve workflow source; drag positions reach host layout storage. + +- **Starting point:** I am viewing the graph. +- **My flow:** I pan or zoom → drag job cards → collapse or expand cards → use + **Fit view** to see the graph, or **Auto layout** to rearrange it. +- **What I see:** The visual arrangement changes without changing the YAML. + Whether the arrangement survives reopening depends on the host's layout + storage. + +### UF-05 — Navigate in a narrow container + +**UI tests**: [UI tests](../ui/test/browser/flows/uf-05.spec.ts) + +- **Coverage:** Narrow drawers isolate hidden content and keep YAML usable in a short container. + +- **Starting point:** The editor is embedded in a narrow space. +- **My flow:** I use **palette**, **canvas**, and **inspector** to switch panels + → select a job or step → switch to YAML or Split when I need the source. +- **What I see:** The editor initially uses Visual mode in a narrow container. + Selecting an item opens the inspector; hidden side panels stay out of the + keyboard tab order. + +## Creating and configuring a workflow + +### UF-06 — Start a new workflow + +**UI tests**: [UI tests](../ui/test/browser/flows/uf-06.spec.ts) + +- **Coverage:** The starter has no browser admission errors and Undo restores the empty source. + +- **Starting point:** The host opens an empty document. +- **My flow:** I select **Create workflow** → inspect the first job → change + the workflow name through **Workflow properties**. +- **What I see:** An untitled workflow appears with a manual trigger and one + job containing an example direct command. I can use **Undo** to return to + the empty document. I can also start by typing YAML myself. + +### UF-07 — Change workflow properties and triggers + +**UI tests**: [UI tests](../ui/test/browser/flows/uf-07.spec.ts) + +- **Coverage:** Name, both trigger toggles, and the advanced trigger mapping update the definition. + +- **Starting point:** An editable workflow is open. +- **My flow:** I select **Workflow properties** → edit **Name** → enable or + disable **manual** and **workflow_call** under **Triggers**. +- **What I see:** The title and YAML reflect my changes. For other trigger + settings, I open **Advanced trigger and interface YAML** and edit the mapping. + +### UF-08 — Define a reusable workflow's interface + +**UI tests**: [UI tests](../ui/test/browser/flows/uf-08.spec.ts) + +- **Coverage:** Callable input type/default/required, secret identifiers, and output expressions are stored through guided controls. + +- **Starting point:** I have enabled **workflow_call** in Workflow properties. +- **My flow:** I open **Callable inputs**, **Callable secrets**, or **Callable + outputs** → select **Add input**, **Add secret**, or **Add output** → edit + the generated identifier and its settings. +- **What I see:** Inputs offer a type, YAML default, and required checkbox; + secrets offer a required checkbox; outputs offer a value expression. These + controls define the interface, rather than entering actual secret values. + +## Authoring jobs and dependencies + +### UF-09 — Add a job + +**UI tests**: [UI tests](../ui/test/browser/flows/uf-09.spec.ts) + +- **Coverage:** Job creation assigns unique IDs and stores the edited name and runner. + +- **Starting point:** I have a usable, editable workflow. +- **My flow:** I select **Add job** → edit **Job ID**, **Name**, and, if needed, + **Runner** in the inspector. +- **What I see:** A new job with a unique ID and an example command appears, + is selected, and is placed clear of existing cards. + +### UF-10 — Make one job depend on another + +**UI tests**: [UI tests](../ui/test/browser/flows/uf-10.spec.ts) + +- **Coverage:** Real connector gestures create an edge and reject a cycle without adding a dependency to the prerequisite. + +- **Starting point:** My workflow has at least two jobs. +- **My flow:** I drag from the bottom connector of the prerequisite job to the + top connector of the dependent job. Alternatively, I select the dependent + job and check the prerequisite under **Dependencies**. +- **What I see:** An arrow and the corresponding YAML dependency appear. + Self-dependencies, duplicate connections, and cycles are rejected with an + error, leaving the dependency unchanged. + +### UF-11 — Remove a dependency + +**UI tests**: [UI tests](../ui/test/browser/flows/uf-11.spec.ts) + +- **Coverage:** Unchecking a prerequisite removes both its structural dependency and graph edge. + +- **Starting point:** A job depends on another job. +- **My flow:** I select the dependent job → uncheck the prerequisite in + **Dependencies**. +- **What I see:** The dependency arrow and YAML entry are removed. I review + diagnostics for expressions that may still rely on the removed dependency. + +### UF-12 — Call another workflow + +**UI tests**: [UI tests](../ui/test/browser/flows/uf-12.spec.ts) + +- **Coverage:** Reusable conversion stores call inputs and secrets, removes steps, and supports Undo. + +- **Starting point:** I want a job to invoke a reusable workflow. +- **My flow:** If the host provides a catalog, I choose an entry under + **Reusable workflows**. Otherwise, I select a job, change **Job kind** to + **Reusable workflow**, and edit **Reusable workflow** → fill in **Call inputs + (YAML)** and **Call secrets (YAML)** as needed. +- **What I see:** The job shows a reusable-call badge and reference instead of + a step list. Changing an existing sequential job to this kind replaces its + steps; **Undo** restores the prior definition if needed. + +### UF-13 — Repeat a job until a condition is met + +**UI tests**: [UI tests](../ui/test/browser/flows/uf-13.spec.ts) + +- **Coverage:** Imported null values recover through guided controls; repeat configuration can be added and removed. + +- **Starting point:** A job is selected. +- **My flow:** I open **Repeat** → enter **Maximum attempts** and **Until** → + review the resulting YAML and diagnostics. +- **What I see:** The job card displays its repeat bound. **Remove repeat** + removes the repeat configuration. + +### UF-14 — Expose a job output + +**UI tests**: [UI tests](../ui/test/browser/flows/uf-14.spec.ts) + +- **Coverage:** Named output fields and advanced output mappings update the job definition. + +- **Starting point:** A job is selected. +- **My flow:** I open **Job outputs** → select **Add output** → edit its + identifier and **Output value**, using an expression when appropriate. +- **What I see:** The output is added to the job definition and becomes + available in relevant expression suggestions. **Advanced output YAML** lets + me edit the complete output mapping. + +## Authoring steps + +### UF-15 — Add a step to a job + +**UI tests**: [UI tests](../ui/test/browser/flows/uf-15.spec.ts) + +- **Coverage:** Templates append unique explicit IDs and stay disabled without a sequential job. + +- **Starting point:** I have selected a job with sequential steps. +- **My flow:** I choose a step template from **Add to workflow**, using + **Search palette** if needed, or select **Add step** in the job inspector. +- **What I see:** A step with a unique explicit ID is appended and selected. + Step templates are unavailable until I select a job and cannot be appended + to a reusable-call job. + +### UF-16 — Configure an agent task + +**UI tests**: [UI tests](../ui/test/browser/flows/uf-16.spec.ts) + +- **Coverage:** Agent settings include prompt, message, model, workspace, and structured output; imported null command fields preserve agent inspection. + +- **Starting point:** I add or select an agent step. +- **My flow:** I choose the agent under **Step type**, or edit **Action target** + → write the **Prompt** → optionally set **Message**, **Model**, **Workspace**, + and a **Structured output contract (YAML)**. +- **What I see:** The step definition and graph label update. If the host + supplies an agent catalog, an unavailable agent produces a warning. Output + contracts may still require host validation before execution. + +### UF-17 — Configure a direct command + +**UI tests**: [UI tests](../ui/test/browser/flows/uf-17.spec.ts) + +- **Coverage:** Argument arrays, working directories, timeouts, and direct-command labels reflect form edits. + +- **Starting point:** I add **Direct command** or choose it under Step type. +- **My flow:** I enter the executable and arguments in **Arguments (YAML + array)** → optionally set **Working directory** and **Timeout**. +- **What I see:** The step stores an argument array and is labelled as a direct + command. Diagnostics help me spot invalid command configuration. + +### UF-18 — Configure a shell command + +**UI tests**: [UI tests](../ui/test/browser/flows/uf-18.spec.ts) + +- **Coverage:** Shell, command, working directory, and timeout are stored through guided controls. + +- **Starting point:** I add **Shell command** or choose it under Step type. +- **My flow:** I set **Shell** and write **Command** → optionally set **Working + directory** and **Timeout**. +- **What I see:** The step stores the shell and command text, and the graph + identifies it as a shell command. + +### UF-19 — Add workspace, Git, or pull request actions + +**UI tests**: [UI tests](../ui/test/browser/flows/uf-19.spec.ts) + +- **Coverage:** Parameterized cases author all five controller actions and inspect each configured parameter. + +- **Starting point:** I have selected a sequential job. +- **My flow:** I choose an action template → fill in its inspector fields → + review the resulting YAML. +- **What I configure:** + + | Action | Fields I fill in | + | --- | --- | + | `workspace/create` | Commit and Independent workspace | + | `workspace/snapshot` | Commit | + | `workspace/integrate` | Path and Commits (YAML) | + | `git/commit` | Path and Message | + | `github/pr` | Path, Commit, Title, and Body | + +- **What I see:** The action is part of the workflow definition. Authoring it + does not create a workspace, commit, or pull request. + +### UF-20 — Use a custom action form + +**UI tests**: [UI tests](../ui/test/browser/flows/uf-20.spec.ts) + +- **Coverage:** Host fields and the custom prompt renderer update the appended action parameters. + +- **Starting point:** The host provides custom action choices and forms. +- **My flow:** I add the action from the palette or select its Step type → + complete the custom fields in the inspector. +- **What I see:** My changes appear in the step's YAML parameters. Available + controls depend on the host application. + +### UF-21 — Change a step's type + +**UI tests**: [UI tests](../ui/test/browser/flows/uf-21.spec.ts) + +- **Coverage:** Changing type clears previous command settings; Undo restores them together. + +- **Starting point:** A step already has command or action settings. +- **My flow:** I select a different **Step type** → fill in the new type's + fields → inspect the YAML before continuing. +- **What I see:** The new type gets its starting configuration. Previous + type-specific settings, including parameters, timeout, and working directory, + are replaced. I can use **Undo** to restore them. + +### UF-22 — Control conditions and error handling + +**UI tests**: [UI tests](../ui/test/browser/flows/uf-22.spec.ts) + +- **Coverage:** Job and step conditions and error flags update the definition and graph indicators. + +- **Starting point:** I have selected a job or step. +- **My flow:** I enter **Condition** and optionally check **Continue on error**. +- **What I see:** The YAML updates and the graph shows the corresponding + condition or error-handling indicator. The UI does not evaluate the condition + or predict its execution result. + +### UF-23 — Insert a reference into an expression + +**UI tests**: [UI tests](../ui/test/browser/flows/uf-23.spec.ts) + +- **Coverage:** Expression insertion replaces selected text and preserves the cursor; YAML completion offers preceding steps and property hints exclude literal fields. + +- **Starting point:** I am editing a field that supports expressions. +- **My flow:** I use field suggestions, choose **Insert expression…** in a + supported multiline field, or press **Ctrl-Space** at the relevant YAML + cursor position → select an input or output reference → continue typing. +- **What I see:** The expression is inserted at my cursor or replaces my text + selection. Suggestions depend on context, including preceding steps and + dependency outputs; literal metadata fields do not offer expressions. + +## Revising and removing content + +### UF-24 — Rename an identifier safely + +**UI tests**: [UI tests](../ui/test/browser/flows/uf-24.spec.ts) + +- **Coverage:** Conflicting IDs can be corrected; callable renames rewrite references and deletion exposes affected paths. + +- **Starting point:** I select a job, step, callable interface entry, or job output. +- **My flow:** I edit **Job ID**, **Step ID**, or the entry's **Identifier** → + apply the edit → review affected expressions and diagnostics. +- **What I see:** Recognized references update together with the identifier; + job dependencies also follow job renames. Invalid or conflicting identifiers + show an error so I can correct them. Computed or bracket-style expression + references need manual review. + +### UF-25 — Duplicate a job or step + +**UI tests**: [UI tests](../ui/test/browser/flows/uf-25.spec.ts) + +- **Coverage:** Job and step copies have unique identifiers and each duplication is undoable. + +- **Starting point:** A job or step is selected. +- **My flow:** I select **Duplicate job** or **Duplicate step** → inspect the + copy → adjust its name, settings, and references for its new purpose. +- **What I see:** A copy with a unique identifier appears. I can undo the + duplication as one document change. + +### UF-26 — Change the order of steps + +**UI tests**: [UI tests](../ui/test/browser/flows/uf-26.spec.ts) + +- **Coverage:** Reordering unnamed steps materializes stable IDs, preserves references, and reverses through Undo. + +- **Starting point:** I select a step in a sequential job. +- **My flow:** I select **Move up** or **Move down** → review the step list and + any expressions that depend on earlier steps. +- **What I see:** The graph and YAML show the new order. Existing unnamed + steps receive explicit IDs before structural changes so their original + identities are preserved. Boundary move buttons are disabled. + +### UF-27 — Delete a job, step, input, secret, or output + +**UI tests**: [UI tests](../ui/test/browser/flows/uf-27.spec.ts) + +- **Coverage:** Deletion exposes structural impacts; Cancel preserves source and a host replacement invalidates a pending confirmation. + +- **Starting point:** I select the item and its delete control. +- **My flow:** I read the confirmation and affected document paths → choose + **Cancel** or **Confirm delete**. +- **What I see:** Cancel preserves the document. Confirmation removes the + item; deleting a job also removes structural dependencies on it. Expression + references remain and receive diagnostics so I can repair them. If the + document changes while the dialog is open, I must review deletion again. + +### UF-28 — Undo or redo my edits + +**UI tests**: [UI tests](../ui/test/browser/flows/uf-28.spec.ts) + +- **Coverage:** YAML and form changes share Undo and Redo while subsequent typing stays at the restored cursor. + +- **Starting point:** I have changed the document through forms, the graph, or YAML. +- **My flow:** I select **Undo** or **Redo**, or use **Mod-Z** and + **Mod-Shift-Z** in the YAML editor; YAML also supports **Mod-Y** for redo. +- **What I see:** Document edits share a history across all three editing + surfaces. Navigation, zoom, and card positions are separate from that history. + While typing in a property field, native text undo applies to its local draft. + +## Editing source and recovering from errors + +### UF-29 — Edit or paste workflow YAML + +**UI tests**: [UI tests](../ui/test/browser/flows/uf-29.spec.ts) + +- **Coverage:** Pasting source updates the graph and retains comments and unfamiliar fields. + +- **Starting point:** I choose YAML or Split mode in an editable document. +- **My flow:** I type or paste YAML into **Workflow YAML** → inspect the graph, + inspector, and diagnostics as the document updates. +- **What I see:** Usable source updates the visual representation. Comments + and unfamiliar fields remain available in the source; visual edits may + normalize formatting. Pasting source replaces text I select, rather than + opening a file-import dialog. + +### UF-30 — Search and replace in YAML + +**UI tests**: [UI tests](../ui/test/browser/flows/uf-30.spec.ts) + +- **Coverage:** Search and replacement work in narrow light and dark containers with visible labels and focus restoration. + +- **Starting point:** The YAML pane is visible and focused. +- **My flow:** I press **Mod-F** → enter my search → navigate matches → use the + search panel's replacement controls when I want to change matching text. +- **What I see:** Matches are highlighted. Replacements update the workflow + and can be undone; in the viewer I can search but cannot replace source. + +### UF-31 — Fix validation problems + +**UI tests**: [UI tests](../ui/test/browser/flows/uf-31.spec.ts) + +- **Coverage:** Correcting command arguments clears browser errors; rejected host validation appears in diagnostics. + +- **Starting point:** The diagnostics area reports warnings or errors. +- **My flow:** I expand diagnostics → read the message, document path, source, + and line/column when available → locate the field or YAML → correct it. +- **What I see:** Diagnostics and the YAML error count update. If host + validation is configured, I may see **Validating…** while it runs. A host + validation failure is reported as a diagnostic; browser checks alone do not + guarantee that the runner will accept the workflow. + +### UF-32 — Recover from malformed YAML + +**UI tests**: [UI tests](../ui/test/browser/flows/uf-32.spec.ts) + +- **Coverage:** Malformed source retains its last preview until a YAML repair; stale previews discard unapplied property drafts before editing resumes. + +- **Starting point:** My source becomes malformed or structurally unusable. +- **My flow:** I read **Stale preview — fix the YAML structure to resume visual + editing.** → fix the YAML, or undo the breaking edit. +- **What I see:** My source stays available. If there was a usable graph, it + remains visible as the last preview, while visual authoring is disabled and + source navigation from the preview is suspended. Recovery restores visual + editing. If no usable workflow exists, **Create workflow** offers a starter + replacement that I can undo. + +### UF-33 — Correct an invalid inspector field + +**UI tests**: [UI tests](../ui/test/browser/flows/uf-33.spec.ts) + +- **Coverage:** Correction, Escape, and Discard edit recover rejected YAML and identifier drafts; recovery alone never invokes saving. + +- **Starting point:** A structured YAML field or identifier rejects my edit. +- **My flow:** I read the error → correct the value and apply it again, or press + **Escape** in the field or select **Discard edit** to restore the stored value. +- **What I see:** An invalid YAML draft in a structured field does not replace + the stored field value. The field is marked invalid and explains that its edit has not been applied. Escape and Discard edit restore the stored value and clear the rejection. I select Save again after recovery. + +## Saving, viewing, and the playground + +### UF-34 — Save my workflow + +**UI tests**: [UI tests](../ui/test/browser/flows/uf-34.spec.ts) + +- **Coverage:** Toolbar and keyboard saves reject invalid YAML and all built-in identifier drafts without calling the host. Desktop and narrow layouts reveal collapsed rejected fields, track multiple errors, and allow explicit recovery. Corrected job IDs save in the same toolbar click. Active valid drafts apply once without erasing a rejected sibling draft; editors save independently, in-flight saves are guarded, malformed source remains saveable, and failures remain retryable. + +- **Starting point:** The host provides a **Save** button. +- **My flow:** I finish my edit → select **Save**, or press **Mod-S** to apply + pending inspector fields and save → wait for **Saving…** to finish. +- **What I see:** The host handles the submitted source. If saving fails, an + alert lets me read and **Dismiss** the error; I can retry. If an inspector edit cannot apply, saving stops and its field is revealed and focused. I correct it, press Escape, or choose Discard edit, then explicitly save again. Save accepts source drafts + with diagnostics and is never automatic. Without host saving, I use export. + +### UF-35 — Download the YAML + +**UI tests**: [UI tests](../ui/test/browser/flows/uf-35.spec.ts) + +- **Coverage:** Downloads preserve original source bytes and include malformed drafts. + +- **Starting point:** A workflow or draft is open in the editor or viewer. +- **My flow:** I select **Export YAML** → receive `workflow.yml` through my + browser's download handling. +- **What I see:** The download contains the current source, including a draft + with diagnostics. If I have not edited the source, its original bytes are + preserved. Canvas layout is separate from the exported YAML. + +### UF-36 — Inspect a workflow in read-only mode + +**UI tests**: [UI tests](../ui/test/browser/flows/uf-36.spec.ts) + +- **Coverage:** Viewer inspection and expansion remain available while source and mutation controls are read-only. + +- **Starting point:** The host opens the workflow in the **Viewer**. +- **My flow:** I navigate jobs and steps → inspect properties → switch views + → search YAML → expand cards, pan, or zoom → optionally export YAML. +- **What I see:** I can explore the definition while document-changing controls + are unavailable or read-only. The host can still replace the displayed source. + +### UF-37 — Explore the local playground + +**UI tests**: [UI tests](../ui/test/browser/flows/uf-37.spec.ts) + +- **Coverage:** All four examples export unchanged source; theme controls, example-named saves, layout storage, and reloading original source are exercised. + +- **Starting point:** I open the development playground. +- **My flow:** I choose an **Example** (`classify`, `parallel-review`, `develop`, + or `develop-iteration`) → explore or edit it → choose **Theme** (`system`, + `light`, or `dark`) → use **Save** or **Export YAML** to download my changes. +- **What I see:** Save downloads an example-named YAML file. The playground + remembers layout separately for each example in browser storage; switching + examples reloads the selected example's original source, so I download edits + I want to keep before switching. + +## Connecting work across jobs and steps + +### UF-38 — Plan parallel work followed by a combined result + +**UI tests**: [UI tests](../ui/test/browser/flows/uf-38.spec.ts) + +- **Coverage:** Two review jobs remain independent while both feed the summary through dependency checkboxes. + +- **Starting point:** I want two independent reviews and a final summary. +- **My flow:** I add jobs named `review_a`, `review_b`, and `summarize` → add + the review steps → select `summarize` and check both review jobs under + **Dependencies** → use **Auto layout** to inspect the structure. +- **What I see:** Both review jobs lead into the summary job, with no dependency + between the reviews. The definition allows the reviews to run independently + before the summary, subject to the runner's execution capacity. + +### UF-39 — Pass a result from one job to another + +**UI tests**: [UI tests](../ui/test/browser/flows/uf-39.spec.ts) + +- **Coverage:** A declared producer output becomes an expression option for a dependent summary prompt. + +- **Starting point:** My `review` job contains an agent step with ID `analyze`, + and a `summarize` job needs its result. +- **My flow:** In `review`, I add a **Job output** named `report` with value + `${{ steps.analyze.outputs.text }}` → select `summarize` and check `review` + under **Dependencies** → select the summary agent step and insert + `${{ needs.review.outputs.report }}` into its **Prompt**. +- **What I see:** The YAML contains both the dependency and the data reference. + The dependent job's expression suggestions include the declared `report` + output. I am defining how results will be passed; actual values appear when + the workflow runs outside this UI. + +### UF-40 — Use an earlier step's result in a later step + +**UI tests**: [UI tests](../ui/test/browser/flows/uf-40.spec.ts) + +- **Coverage:** Only preceding command outputs appear in the prompt picker; reordering preserves the reference for manual review. + +- **Starting point:** A sequential job has a direct command with ID `collect` + followed by an agent step. +- **My flow:** I select the later agent step → write a prompt explaining what + to do with the command result → insert `${{ steps.collect.outputs.stdout }}` + using **Insert expression…** → review the step order. +- **What I see:** The prompt references the earlier command's output. Suggestions + offer preceding steps rather than later steps. If I move the agent before + `collect`, I need to correct the order or reference before execution. + +## Additional editing and recovery + +### UF-41 — Edit action parameters beyond the guided form + +**UI tests**: [UI tests](../ui/test/browser/flows/uf-41.spec.ts) + +- **Coverage:** Replacing advanced parameters updates both the stored mapping and guided prompt/model fields. + +- **Starting point:** A selected action needs parameters that its guided fields + do not expose, or I want to edit several parameters together. +- **My flow:** I open **Advanced parameters** → edit **Parameters (YAML)** → + leave the field to apply the mapping → inspect any guided fields and diagnostics. +- **What I see:** The action's complete parameter mapping is replaced by my + entry, and guided fields reflect the resulting values. I include existing + parameters I want to keep; replacing the mapping also replaces comments + inside it. Invalid YAML stays a field draft until I correct or cancel it. + +### UF-42 — Replace a reusable call with local steps + +**UI tests**: [UI tests](../ui/test/browser/flows/uf-42.spec.ts) + +- **Coverage:** Sequential conversion removes call settings, creates a starter command, and Undo restores the call. + +- **Starting point:** A job currently calls a reusable workflow, and I want to + define its steps directly. +- **My flow:** I select the job → change **Job kind** to **Sequential steps** + → edit the starter command or add the steps I need → review the job outputs + and any downstream references. +- **What I see:** The reusable reference, call inputs, and call secrets are + removed, and an example direct-command step appears. The reusable workflow's + contents are not copied into the job. **Undo** restores the previous call. + +### UF-43 — Repair references after removing an item + +**UI tests**: [UI tests](../ui/test/browser/flows/uf-43.spec.ts) + +- **Coverage:** Deleting a referenced step reports its consuming path; editing the remaining prompt clears the reference diagnostic. + +- **Starting point:** I confirmed deletion of a referenced job, step, input, + secret, or output, and diagnostics now identify remaining references. +- **My flow:** I read the affected paths → navigate to each expression → + replace the reference with a valid input or output, remove the expression, + or undo the deletion if I still need the original item. +- **What I see:** Diagnostics update as I repair references. If I replace a + deleted job with a different producer, I also select that producer under the + consuming job's **Dependencies**; editing an expression alone adds no edge. + +### UF-44 — Resolve an unavailable agent or reusable workflow warning + +**UI tests**: [UI tests](../ui/test/browser/flows/uf-44.spec.ts) + +- **Coverage:** Correcting unavailable agent and workflow references clears catalog warnings while preserving agent parameters. + +- **Starting point:** The host supplies catalogs, and diagnostics say a + referenced agent or workflow is unavailable. +- **My flow:** I select the affected step or job → review its **Action target** + or **Reusable workflow** → correct the reference to an available catalog + entry → review its parameters and diagnostics. +- **What I see:** The warning clears when the reference matches the catalog. + Editing **Action target** keeps the current parameters; changing **Step type** + replaces type-specific settings as described in UF-21. Catalog membership + does not verify provider authentication or resolve the reusable file. + +### UF-45 — Author a workflow using the keyboard + +**UI tests**: [UI tests](../ui/test/browser/flows/uf-45.spec.ts) + +- **Coverage:** Keyboard job navigation, field commits, and dependency toggles author a workflow; deletion traps focus and Escape restores the originating button. + +- **Starting point:** I am in an editable workflow and prefer keyboard controls. +- **My flow:** I use Tab and Shift-Tab to reach the palette's job buttons → + activate a job with Enter or Space → tab through inspector fields → apply + single-line edits with Enter → toggle **Dependencies** with Space → use + **Mod-S** when saving is available. +- **What I see:** I can author dependencies without dragging canvas connectors. + If I open a deletion dialog, focus starts on **Cancel**, Tab stays inside the + dialog, and Escape cancels and returns focus to the originating control when + it is still present. + +### UF-46 — Make a focused edit to a hand-written workflow + +**UI tests**: [UI tests](../ui/test/browser/flows/uf-46.spec.ts) + +- **Coverage:** A focused name edit preserves unrelated comments, metadata, prompt text, and mapping order in the export. + +- **Starting point:** The host opens an existing workflow containing comments, + multiline prompts, or fields without dedicated form controls. +- **My flow:** I choose **Split** → inspect the source → change a supported + property in the inspector → review the surrounding YAML → select + **Export YAML** to keep the updated definition. +- **What I see:** Ordinary property edits preserve unrelated comments, mapping + order, and unrecognized fields, although formatting can change. Unknown + fields may still produce diagnostics. I review whole-mapping replacements + separately because they replace that subtree and its comments. + +### UF-47 — Recover my draft after the host replaces the source + +**UI tests**: [UI tests](../ui/test/browser/flows/uf-47.spec.ts) + +- **Coverage:** Undo after a host replacement restores the prior local draft for export. + +- **Starting point:** The host replaces the definition in the same open editor + instance, for example after loading an updated version. +- **My flow:** I inspect the new graph and YAML → select **Undo** if I want my + previous draft back → review it and save or export the version I want to keep. +- **What I see:** Source replacement participates in the document history, so + the previous draft can be restored while that editor instance remains open. + This is document undo; it does not reverse storage changes already made by + the host or recover history after the editor is destroyed and reopened. + +## Implementation references + +These flows describe existing behavior rather than proposed screens. Their +sources are the [UI guide](../ui/README.md), +[editor controls](../ui/src/editor.tsx), +[property forms](../ui/src/forms.tsx), +[YAML editor](../ui/src/source.tsx), and +[playground](../ui/playground.ts). Related interaction coverage lives in the +[browser tests](../ui/test/browser/). diff --git a/pkg/workflows/ui_compat_test.go b/pkg/workflows/ui_compat_test.go new file mode 100644 index 0000000..1bc28ca --- /dev/null +++ b/pkg/workflows/ui_compat_test.go @@ -0,0 +1,45 @@ +package workflows + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" +) + +// These definitions are also checked by ui/test/compat.test.ts. Keep the +// browser's parser/admission boundary aligned with Parse and Validate. +func TestUICompatibilityFixtures(t *testing.T) { + root := filepath.Join("..", "..", "testdata", "ui-compat") + data, err := os.ReadFile(filepath.Join(root, "cases.json")) + if err != nil { + t.Fatal(err) + } + var cases []struct { + Name string + File string + Parse bool + Valid bool + } + if err := json.Unmarshal(data, &cases); err != nil { + t.Fatal(err) + } + for _, c := range cases { + t.Run(c.Name, func(t *testing.T) { + source, err := os.ReadFile(filepath.Join(root, c.File)) + if err != nil { + t.Fatal(err) + } + workflow, err := Parse(source) + if (err == nil) != c.Parse { + t.Fatalf("Parse: got %v, want success=%t", err, c.Parse) + } + if err == nil { + err = Validate(workflow) + if (err == nil) != c.Valid { + t.Fatalf("Validate: got %v, want success=%t", err, c.Valid) + } + } + }) + } +} diff --git a/testdata/ui-compat/action-parameter.yml b/testdata/ui-compat/action-parameter.yml new file mode 100644 index 0000000..21848e8 --- /dev/null +++ b/testdata/ui-compat/action-parameter.yml @@ -0,0 +1 @@ +jobs: {build: {steps: [{uses: workspace/create, with: {bad: yes}}]}} \ No newline at end of file diff --git a/testdata/ui-compat/alias.yml b/testdata/ui-compat/alias.yml new file mode 100644 index 0000000..a2336d9 --- /dev/null +++ b/testdata/ui-compat/alias.yml @@ -0,0 +1 @@ +jobs: {build: &job {steps: [{argv: [echo]}]}, other: *job} \ No newline at end of file diff --git a/testdata/ui-compat/bad-id.yml b/testdata/ui-compat/bad-id.yml new file mode 100644 index 0000000..1a86fb9 --- /dev/null +++ b/testdata/ui-compat/bad-id.yml @@ -0,0 +1 @@ +jobs: {"123": {steps: [{argv: [echo]}]}} \ No newline at end of file diff --git a/testdata/ui-compat/cases.json b/testdata/ui-compat/cases.json new file mode 100644 index 0000000..88c835d --- /dev/null +++ b/testdata/ui-compat/cases.json @@ -0,0 +1,380 @@ +[ + { + "name": "classify", + "file": "../../workflows/classify.yml", + "parse": true, + "valid": true + }, + { + "name": "parallel-review", + "file": "../../workflows/parallel-review.yml", + "parse": true, + "valid": true + }, + { + "name": "develop", + "file": "../../workflows/develop.yml", + "parse": true, + "valid": true + }, + { + "name": "develop-iteration", + "file": "../../workflows/develop-iteration.yml", + "parse": true, + "valid": true + }, + { + "name": "command", + "file": "command.yml", + "parse": true, + "valid": true + }, + { + "name": "shell", + "file": "shell.yml", + "parse": true, + "valid": true + }, + { + "name": "dependency", + "file": "dependency.yml", + "parse": true, + "valid": false + }, + { + "name": "cycle", + "file": "cycle.yml", + "parse": true, + "valid": false + }, + { + "name": "run-without-shell", + "file": "run-without-shell.yml", + "parse": true, + "valid": false + }, + { + "name": "command-with-action", + "file": "command-with-action.yml", + "parse": true, + "valid": false + }, + { + "name": "command-timeout", + "file": "command-timeout.yml", + "parse": true, + "valid": false + }, + { + "name": "action-parameter", + "file": "action-parameter.yml", + "parse": true, + "valid": false + }, + { + "name": "repeat-zero", + "file": "repeat-zero.yml", + "parse": true, + "valid": false + }, + { + "name": "repeat-large", + "file": "repeat-large.yml", + "parse": true, + "valid": false + }, + { + "name": "repeat-boundary", + "file": "repeat-boundary.yml", + "parse": true, + "valid": true + }, + { + "name": "alias", + "file": "alias.yml", + "parse": false, + "valid": false + }, + { + "name": "duplicate", + "file": "duplicate.yml", + "parse": false, + "valid": false + }, + { + "name": "multiple-documents", + "file": "multiple-documents.yml", + "parse": false, + "valid": false + }, + { + "name": "merge", + "file": "merge.yml", + "parse": false, + "valid": false + }, + { + "name": "unknown-field", + "file": "unknown-field.yml", + "parse": false, + "valid": false + }, + { + "name": "shape", + "file": "shape.yml", + "parse": false, + "valid": false + }, + { + "name": "unsupported-trigger", + "file": "unsupported-trigger.yml", + "parse": true, + "valid": false + }, + { + "name": "bad-id", + "file": "bad-id.yml", + "parse": true, + "valid": false + }, + { + "name": "empty", + "file": "empty.yml", + "parse": false, + "valid": false + }, + { + "name": "reserved-object-ids", + "file": "reserved-object-ids.yml", + "parse": true, + "valid": true + }, + { + "name": "unknown-action", + "file": "unknown-action.yml", + "parse": true, + "valid": false + }, + { + "name": "sub-nanosecond-timeout", + "file": "sub-nanosecond-timeout.yml", + "parse": true, + "valid": false + }, + { + "name": "overflow-timeout", + "file": "overflow-timeout.yml", + "parse": true, + "valid": false + }, + { + "name": "dependency-list-normalization", + "file": "dependency-list-normalization.yml", + "parse": true, + "valid": true + }, + { + "name": "dependency-scalar-boolean", + "file": "dependency-scalar-boolean.yml", + "parse": true, + "valid": true + }, + { + "name": "dependency-list-scalars", + "file": "dependency-list-scalars.yml", + "parse": true, + "valid": true + }, + { + "name": "dependency-numeric-spelling", + "file": "dependency-numeric-spelling.yml", + "parse": true, + "valid": false + }, + { + "name": "dependency-list-mapping", + "file": "dependency-list-mapping.yml", + "parse": false, + "valid": false + }, + { + "name": "scalar-string-fields", + "file": "scalar-string-fields.yml", + "parse": true, + "valid": true + }, + { + "name": "scalar-key-duplicate", + "file": "scalar-key-duplicate.yml", + "parse": false, + "valid": false + }, + { + "name": "legacy-boolean-fields", + "file": "legacy-boolean-fields.yml", + "parse": true, + "valid": true + }, + { + "name": "repeat-fractional", + "file": "repeat-fractional.yml", + "parse": true, + "valid": true + }, + { + "name": "repeat-fractional-upper", + "file": "repeat-fractional-upper.yml", + "parse": true, + "valid": true + }, + { + "name": "repeat-fractional-zero", + "file": "repeat-fractional-zero.yml", + "parse": true, + "valid": false + }, + { + "name": "repeat-fractional-negative", + "file": "repeat-fractional-negative.yml", + "parse": true, + "valid": false + }, + { + "name": "repeat-fractional-over-bound", + "file": "repeat-fractional-over-bound.yml", + "parse": true, + "valid": false + }, + { + "name": "repeat-octal", + "file": "repeat-octal.yml", + "parse": true, + "valid": true + }, + { + "name": "repeat-decimal-leading-zero", + "file": "repeat-decimal-leading-zero.yml", + "parse": true, + "valid": true + }, + { + "name": "repeat-exponent", + "file": "repeat-exponent.yml", + "parse": true, + "valid": true + }, + { + "name": "repeat-infinity", + "file": "repeat-infinity.yml", + "parse": false, + "valid": false + }, + { + "name": "repeat-nan", + "file": "repeat-nan.yml", + "parse": false, + "valid": false + }, + { + "name": "repeat-integer-max", + "file": "repeat-integer-max.yml", + "parse": true, + "valid": false + }, + { + "name": "repeat-integer-overflow", + "file": "repeat-integer-overflow.yml", + "parse": false, + "valid": false + }, + { + "name": "explicit-scalar-tags", + "file": "explicit-scalar-tags.yml", + "parse": true, + "valid": true + }, + { + "name": "null-argv-only", + "file": "null-argv-only.yml", + "parse": true, + "valid": false + }, + { + "name": "null-argv-elements", + "file": "null-argv-elements.yml", + "parse": true, + "valid": true + }, + { + "name": "null-step-elements", + "file": "null-step-elements.yml", + "parse": true, + "valid": true + }, + { + "name": "null-steps-only", + "file": "null-steps-only.yml", + "parse": true, + "valid": false + }, + { + "name": "null-job", + "file": "null-job.yml", + "parse": true, + "valid": false + }, + { + "name": "null-callable-entries", + "file": "null-callable-entries.yml", + "parse": true, + "valid": true + }, + { + "name": "null-optional-mappings", + "file": "null-optional-mappings.yml", + "parse": true, + "valid": true + }, + { + "name": "null-triggers", + "file": "null-triggers.yml", + "parse": true, + "valid": true + }, + { + "name": "root-null", + "file": "root-null.yml", + "parse": true, + "valid": false + }, + { + "name": "root-scalar", + "file": "root-scalar.yml", + "parse": false, + "valid": false + }, + { + "name": "root-sequence", + "file": "root-sequence.yml", + "parse": false, + "valid": false + }, + { + "name": "null-steps", + "file": "null-steps.yml", + "parse": true, + "valid": false + }, + { + "name": "typed-null-scalars", + "file": "typed-null-scalars.yml", + "parse": true, + "valid": true + }, + { + "name": "tagged-legacy-booleans", + "file": "tagged-legacy-booleans.yml", + "parse": true, + "valid": true + } +] diff --git a/testdata/ui-compat/command-timeout.yml b/testdata/ui-compat/command-timeout.yml new file mode 100644 index 0000000..043838c --- /dev/null +++ b/testdata/ui-compat/command-timeout.yml @@ -0,0 +1 @@ +jobs: {build: {steps: [{argv: [echo], timeout: 0s}]}} \ No newline at end of file diff --git a/testdata/ui-compat/command-with-action.yml b/testdata/ui-compat/command-with-action.yml new file mode 100644 index 0000000..4245d87 --- /dev/null +++ b/testdata/ui-compat/command-with-action.yml @@ -0,0 +1 @@ +jobs: {build: {steps: [{argv: [echo], uses: agent/claude}]}} \ No newline at end of file diff --git a/testdata/ui-compat/command.yml b/testdata/ui-compat/command.yml new file mode 100644 index 0000000..f6f9faa --- /dev/null +++ b/testdata/ui-compat/command.yml @@ -0,0 +1,4 @@ +jobs: + build: + steps: + - argv: [echo, ok] diff --git a/testdata/ui-compat/cycle.yml b/testdata/ui-compat/cycle.yml new file mode 100644 index 0000000..f1317be --- /dev/null +++ b/testdata/ui-compat/cycle.yml @@ -0,0 +1 @@ +jobs: {a: {needs: b, steps: [{argv: [echo]}]}, b: {needs: a, steps: [{argv: [echo]}]}} \ No newline at end of file diff --git a/testdata/ui-compat/dependency-list-mapping.yml b/testdata/ui-compat/dependency-list-mapping.yml new file mode 100644 index 0000000..668e585 --- /dev/null +++ b/testdata/ui-compat/dependency-list-mapping.yml @@ -0,0 +1,4 @@ +jobs: + test: + needs: [{bad: value}] + steps: [{argv: [echo]}] diff --git a/testdata/ui-compat/dependency-list-normalization.yml b/testdata/ui-compat/dependency-list-normalization.yml new file mode 100644 index 0000000..38e0b02 --- /dev/null +++ b/testdata/ui-compat/dependency-list-normalization.yml @@ -0,0 +1,6 @@ +jobs: + build: + steps: [{argv: [echo]}] + test: + needs: [" build ", "", null] + steps: [{argv: [echo]}] diff --git a/testdata/ui-compat/dependency-list-scalars.yml b/testdata/ui-compat/dependency-list-scalars.yml new file mode 100644 index 0000000..222bec9 --- /dev/null +++ b/testdata/ui-compat/dependency-list-scalars.yml @@ -0,0 +1,8 @@ +jobs: + "true": + steps: [{argv: [echo]}] + "false": + steps: [{argv: [echo]}] + test: + needs: [true, false, null] + steps: [{argv: [echo]}] diff --git a/testdata/ui-compat/dependency-numeric-spelling.yml b/testdata/ui-compat/dependency-numeric-spelling.yml new file mode 100644 index 0000000..782ce95 --- /dev/null +++ b/testdata/ui-compat/dependency-numeric-spelling.yml @@ -0,0 +1,4 @@ +jobs: + test: + needs: [01, 0xA, 1.0] + steps: [{argv: [echo]}] diff --git a/testdata/ui-compat/dependency-scalar-boolean.yml b/testdata/ui-compat/dependency-scalar-boolean.yml new file mode 100644 index 0000000..1bd64b3 --- /dev/null +++ b/testdata/ui-compat/dependency-scalar-boolean.yml @@ -0,0 +1,6 @@ +jobs: + "true": + steps: [{argv: [echo]}] + test: + needs: true + steps: [{argv: [echo]}] diff --git a/testdata/ui-compat/dependency.yml b/testdata/ui-compat/dependency.yml new file mode 100644 index 0000000..764f4d6 --- /dev/null +++ b/testdata/ui-compat/dependency.yml @@ -0,0 +1 @@ +jobs: {build: {needs: missing, steps: [{argv: [echo]}]}} \ No newline at end of file diff --git a/testdata/ui-compat/duplicate.yml b/testdata/ui-compat/duplicate.yml new file mode 100644 index 0000000..789fd23 --- /dev/null +++ b/testdata/ui-compat/duplicate.yml @@ -0,0 +1,2 @@ +jobs: {} +jobs: {} diff --git a/testdata/ui-compat/empty.yml b/testdata/ui-compat/empty.yml new file mode 100644 index 0000000..e69de29 diff --git a/testdata/ui-compat/explicit-scalar-tags.yml b/testdata/ui-compat/explicit-scalar-tags.yml new file mode 100644 index 0000000..fa40ce1 --- /dev/null +++ b/testdata/ui-compat/explicit-scalar-tags.yml @@ -0,0 +1,8 @@ +name: !!int 01 +jobs: + "TRUE": + repeat: {max-attempts: !!float 1.5, until: done} + steps: [{argv: [echo, !!bool TRUE, !!int 010, !!str 01]}] + test: + needs: !!bool TRUE + steps: [{argv: [echo]}] diff --git a/testdata/ui-compat/legacy-boolean-fields.yml b/testdata/ui-compat/legacy-boolean-fields.yml new file mode 100644 index 0000000..57726b2 --- /dev/null +++ b/testdata/ui-compat/legacy-boolean-fields.yml @@ -0,0 +1,5 @@ +on: {workflow_call: {inputs: {enabled: {required: "no"}}, secrets: {token: {required: YES}}}} +jobs: + test: + continue-on-error: on + steps: [{argv: [echo], continue-on-error: off}] diff --git a/testdata/ui-compat/merge.yml b/testdata/ui-compat/merge.yml new file mode 100644 index 0000000..06718b5 --- /dev/null +++ b/testdata/ui-compat/merge.yml @@ -0,0 +1 @@ +jobs: {build: {<<: {steps: []}}} \ No newline at end of file diff --git a/testdata/ui-compat/multiple-documents.yml b/testdata/ui-compat/multiple-documents.yml new file mode 100644 index 0000000..35bf84a --- /dev/null +++ b/testdata/ui-compat/multiple-documents.yml @@ -0,0 +1,9 @@ +jobs: + build: + steps: + - argv: [echo, ok] +--- +jobs: + build: + steps: + - argv: [echo, ok] diff --git a/testdata/ui-compat/null-argv-elements.yml b/testdata/ui-compat/null-argv-elements.yml new file mode 100644 index 0000000..3bd78ed --- /dev/null +++ b/testdata/ui-compat/null-argv-elements.yml @@ -0,0 +1 @@ +jobs: {test: {steps: [{argv: [echo, null, done]}]}} diff --git a/testdata/ui-compat/null-argv-only.yml b/testdata/ui-compat/null-argv-only.yml new file mode 100644 index 0000000..0df8a57 --- /dev/null +++ b/testdata/ui-compat/null-argv-only.yml @@ -0,0 +1 @@ +jobs: {test: {steps: [{argv: [null]}]}} diff --git a/testdata/ui-compat/null-callable-entries.yml b/testdata/ui-compat/null-callable-entries.yml new file mode 100644 index 0000000..45e5969 --- /dev/null +++ b/testdata/ui-compat/null-callable-entries.yml @@ -0,0 +1,2 @@ +on: {workflow_call: {inputs: {a: null}, secrets: {s: null}, outputs: {o: null}}} +jobs: {test: {steps: [{argv: [echo]}]}} diff --git a/testdata/ui-compat/null-job.yml b/testdata/ui-compat/null-job.yml new file mode 100644 index 0000000..f10c3a4 --- /dev/null +++ b/testdata/ui-compat/null-job.yml @@ -0,0 +1 @@ +jobs: {test: null} diff --git a/testdata/ui-compat/null-optional-mappings.yml b/testdata/ui-compat/null-optional-mappings.yml new file mode 100644 index 0000000..df92dbd --- /dev/null +++ b/testdata/ui-compat/null-optional-mappings.yml @@ -0,0 +1,2 @@ +on: {workflow_call: null, channel_message: null, schedule: [null]} +jobs: {test: {repeat: null, context: null, steps: [{argv: [echo], context: null}]}} diff --git a/testdata/ui-compat/null-step-elements.yml b/testdata/ui-compat/null-step-elements.yml new file mode 100644 index 0000000..4528536 --- /dev/null +++ b/testdata/ui-compat/null-step-elements.yml @@ -0,0 +1 @@ +jobs: {test: {steps: [null, {argv: [echo]}]}} diff --git a/testdata/ui-compat/null-steps-only.yml b/testdata/ui-compat/null-steps-only.yml new file mode 100644 index 0000000..ec19167 --- /dev/null +++ b/testdata/ui-compat/null-steps-only.yml @@ -0,0 +1 @@ +jobs: {test: {steps: [null]}} diff --git a/testdata/ui-compat/null-steps.yml b/testdata/ui-compat/null-steps.yml new file mode 100644 index 0000000..7fc9068 --- /dev/null +++ b/testdata/ui-compat/null-steps.yml @@ -0,0 +1 @@ +jobs: {test: {steps: null}} diff --git a/testdata/ui-compat/null-triggers.yml b/testdata/ui-compat/null-triggers.yml new file mode 100644 index 0000000..249b263 --- /dev/null +++ b/testdata/ui-compat/null-triggers.yml @@ -0,0 +1,2 @@ +on: null +jobs: {test: {steps: [{argv: [echo]}]}} diff --git a/testdata/ui-compat/overflow-timeout.yml b/testdata/ui-compat/overflow-timeout.yml new file mode 100644 index 0000000..2e12813 --- /dev/null +++ b/testdata/ui-compat/overflow-timeout.yml @@ -0,0 +1 @@ +jobs: {build: {steps: [{argv: [echo], timeout: 2562048h}]}} diff --git a/testdata/ui-compat/repeat-boundary.yml b/testdata/ui-compat/repeat-boundary.yml new file mode 100644 index 0000000..16f269a --- /dev/null +++ b/testdata/ui-compat/repeat-boundary.yml @@ -0,0 +1 @@ +jobs: {build: {repeat: {max-attempts: 100, until: done}, steps: [{argv: [echo]}]}} \ No newline at end of file diff --git a/testdata/ui-compat/repeat-decimal-leading-zero.yml b/testdata/ui-compat/repeat-decimal-leading-zero.yml new file mode 100644 index 0000000..d9e293b --- /dev/null +++ b/testdata/ui-compat/repeat-decimal-leading-zero.yml @@ -0,0 +1,4 @@ +jobs: + test: + repeat: {max-attempts: 08, until: done} + steps: [{argv: [echo]}] diff --git a/testdata/ui-compat/repeat-exponent.yml b/testdata/ui-compat/repeat-exponent.yml new file mode 100644 index 0000000..2e95352 --- /dev/null +++ b/testdata/ui-compat/repeat-exponent.yml @@ -0,0 +1,4 @@ +jobs: + test: + repeat: {max-attempts: 1e1, until: done} + steps: [{argv: [echo]}] diff --git a/testdata/ui-compat/repeat-fractional-negative.yml b/testdata/ui-compat/repeat-fractional-negative.yml new file mode 100644 index 0000000..f92fc95 --- /dev/null +++ b/testdata/ui-compat/repeat-fractional-negative.yml @@ -0,0 +1,4 @@ +jobs: + test: + repeat: {max-attempts: -0.9, until: done} + steps: [{argv: [echo]}] diff --git a/testdata/ui-compat/repeat-fractional-over-bound.yml b/testdata/ui-compat/repeat-fractional-over-bound.yml new file mode 100644 index 0000000..f88bed9 --- /dev/null +++ b/testdata/ui-compat/repeat-fractional-over-bound.yml @@ -0,0 +1,4 @@ +jobs: + test: + repeat: {max-attempts: 101.1, until: done} + steps: [{argv: [echo]}] diff --git a/testdata/ui-compat/repeat-fractional-upper.yml b/testdata/ui-compat/repeat-fractional-upper.yml new file mode 100644 index 0000000..f85e9ef --- /dev/null +++ b/testdata/ui-compat/repeat-fractional-upper.yml @@ -0,0 +1,4 @@ +jobs: + test: + repeat: {max-attempts: 100.9, until: done} + steps: [{argv: [echo]}] diff --git a/testdata/ui-compat/repeat-fractional-zero.yml b/testdata/ui-compat/repeat-fractional-zero.yml new file mode 100644 index 0000000..183a6ad --- /dev/null +++ b/testdata/ui-compat/repeat-fractional-zero.yml @@ -0,0 +1,4 @@ +jobs: + test: + repeat: {max-attempts: 0.9, until: done} + steps: [{argv: [echo]}] diff --git a/testdata/ui-compat/repeat-fractional.yml b/testdata/ui-compat/repeat-fractional.yml new file mode 100644 index 0000000..021412e --- /dev/null +++ b/testdata/ui-compat/repeat-fractional.yml @@ -0,0 +1,4 @@ +jobs: + test: + repeat: {max-attempts: 1.5, until: done} + steps: [{argv: [echo]}] diff --git a/testdata/ui-compat/repeat-infinity.yml b/testdata/ui-compat/repeat-infinity.yml new file mode 100644 index 0000000..fa92a9c --- /dev/null +++ b/testdata/ui-compat/repeat-infinity.yml @@ -0,0 +1,4 @@ +jobs: + test: + repeat: {max-attempts: .inf, until: done} + steps: [{argv: [echo]}] diff --git a/testdata/ui-compat/repeat-integer-max.yml b/testdata/ui-compat/repeat-integer-max.yml new file mode 100644 index 0000000..9839ca9 --- /dev/null +++ b/testdata/ui-compat/repeat-integer-max.yml @@ -0,0 +1,4 @@ +jobs: + test: + repeat: {max-attempts: 9223372036854775807, until: done} + steps: [{argv: [echo]}] diff --git a/testdata/ui-compat/repeat-integer-overflow.yml b/testdata/ui-compat/repeat-integer-overflow.yml new file mode 100644 index 0000000..756ec07 --- /dev/null +++ b/testdata/ui-compat/repeat-integer-overflow.yml @@ -0,0 +1,4 @@ +jobs: + test: + repeat: {max-attempts: 9223372036854775808, until: done} + steps: [{argv: [echo]}] diff --git a/testdata/ui-compat/repeat-large.yml b/testdata/ui-compat/repeat-large.yml new file mode 100644 index 0000000..5ff00c0 --- /dev/null +++ b/testdata/ui-compat/repeat-large.yml @@ -0,0 +1 @@ +jobs: {build: {repeat: {max-attempts: 101, until: done}, steps: [{argv: [echo]}]}} \ No newline at end of file diff --git a/testdata/ui-compat/repeat-nan.yml b/testdata/ui-compat/repeat-nan.yml new file mode 100644 index 0000000..7226a97 --- /dev/null +++ b/testdata/ui-compat/repeat-nan.yml @@ -0,0 +1,4 @@ +jobs: + test: + repeat: {max-attempts: .nan, until: done} + steps: [{argv: [echo]}] diff --git a/testdata/ui-compat/repeat-octal.yml b/testdata/ui-compat/repeat-octal.yml new file mode 100644 index 0000000..2bedbab --- /dev/null +++ b/testdata/ui-compat/repeat-octal.yml @@ -0,0 +1,4 @@ +jobs: + test: + repeat: {max-attempts: 010, until: done} + steps: [{argv: [echo]}] diff --git a/testdata/ui-compat/repeat-zero.yml b/testdata/ui-compat/repeat-zero.yml new file mode 100644 index 0000000..81fa665 --- /dev/null +++ b/testdata/ui-compat/repeat-zero.yml @@ -0,0 +1 @@ +jobs: {build: {repeat: {max-attempts: 0, until: done}, steps: [{argv: [echo]}]}} \ No newline at end of file diff --git a/testdata/ui-compat/reserved-object-ids.yml b/testdata/ui-compat/reserved-object-ids.yml new file mode 100644 index 0000000..6f031e1 --- /dev/null +++ b/testdata/ui-compat/reserved-object-ids.yml @@ -0,0 +1 @@ +jobs: {constructor: {steps: [{argv: [echo]}]}, __proto__: {steps: [{argv: [echo]}]}} diff --git a/testdata/ui-compat/root-null.yml b/testdata/ui-compat/root-null.yml new file mode 100644 index 0000000..19765bd --- /dev/null +++ b/testdata/ui-compat/root-null.yml @@ -0,0 +1 @@ +null diff --git a/testdata/ui-compat/root-scalar.yml b/testdata/ui-compat/root-scalar.yml new file mode 100644 index 0000000..ce01362 --- /dev/null +++ b/testdata/ui-compat/root-scalar.yml @@ -0,0 +1 @@ +hello diff --git a/testdata/ui-compat/root-sequence.yml b/testdata/ui-compat/root-sequence.yml new file mode 100644 index 0000000..fe51488 --- /dev/null +++ b/testdata/ui-compat/root-sequence.yml @@ -0,0 +1 @@ +[] diff --git a/testdata/ui-compat/run-without-shell.yml b/testdata/ui-compat/run-without-shell.yml new file mode 100644 index 0000000..151de2c --- /dev/null +++ b/testdata/ui-compat/run-without-shell.yml @@ -0,0 +1 @@ +jobs: {build: {steps: [{run: echo ok}]}} \ No newline at end of file diff --git a/testdata/ui-compat/scalar-key-duplicate.yml b/testdata/ui-compat/scalar-key-duplicate.yml new file mode 100644 index 0000000..01203fa --- /dev/null +++ b/testdata/ui-compat/scalar-key-duplicate.yml @@ -0,0 +1,3 @@ +jobs: + true: {steps: [{argv: [echo]}]} + "true": {steps: [{argv: [echo]}]} diff --git a/testdata/ui-compat/scalar-string-fields.yml b/testdata/ui-compat/scalar-string-fields.yml new file mode 100644 index 0000000..2b114e1 --- /dev/null +++ b/testdata/ui-compat/scalar-string-fields.yml @@ -0,0 +1,8 @@ +name: 42 +jobs: + TRUE: + outputs: {answer: 42} + steps: [{argv: [echo, 01, true]}] + test: + needs: TRUE + steps: [{argv: [echo]}] diff --git a/testdata/ui-compat/shape.yml b/testdata/ui-compat/shape.yml new file mode 100644 index 0000000..1701b76 --- /dev/null +++ b/testdata/ui-compat/shape.yml @@ -0,0 +1 @@ +jobs: {build: {steps: nope}} \ No newline at end of file diff --git a/testdata/ui-compat/shell.yml b/testdata/ui-compat/shell.yml new file mode 100644 index 0000000..154a084 --- /dev/null +++ b/testdata/ui-compat/shell.yml @@ -0,0 +1 @@ +jobs: {build: {steps: [{shell: sh, run: echo ok, timeout: 1m30s}]}} \ No newline at end of file diff --git a/testdata/ui-compat/sub-nanosecond-timeout.yml b/testdata/ui-compat/sub-nanosecond-timeout.yml new file mode 100644 index 0000000..32ba367 --- /dev/null +++ b/testdata/ui-compat/sub-nanosecond-timeout.yml @@ -0,0 +1 @@ +jobs: {build: {steps: [{argv: [echo], timeout: 0.1ns}]}} diff --git a/testdata/ui-compat/tagged-legacy-booleans.yml b/testdata/ui-compat/tagged-legacy-booleans.yml new file mode 100644 index 0000000..5e040ff --- /dev/null +++ b/testdata/ui-compat/tagged-legacy-booleans.yml @@ -0,0 +1,2 @@ +on: {workflow_call: {inputs: {task: {type: string, required: !!str YES}}}} +jobs: {build: {continue-on-error: !!str NO, steps: [{argv: [echo]}]}} diff --git a/testdata/ui-compat/typed-null-scalars.yml b/testdata/ui-compat/typed-null-scalars.yml new file mode 100644 index 0000000..5c57bc1 --- /dev/null +++ b/testdata/ui-compat/typed-null-scalars.yml @@ -0,0 +1,2 @@ +name: null +jobs: {build: {needs: null, continue-on-error: null, outputs: {result: null}, repeat: null, steps: [{id: null, run: null, uses: agent/claude, with: {prompt: ok}}]}} diff --git a/testdata/ui-compat/unknown-action.yml b/testdata/ui-compat/unknown-action.yml new file mode 100644 index 0000000..4e5d64e --- /dev/null +++ b/testdata/ui-compat/unknown-action.yml @@ -0,0 +1 @@ +jobs: {build: {steps: [{uses: constructor}]}} diff --git a/testdata/ui-compat/unknown-field.yml b/testdata/ui-compat/unknown-field.yml new file mode 100644 index 0000000..0805a6c --- /dev/null +++ b/testdata/ui-compat/unknown-field.yml @@ -0,0 +1 @@ +jobs: {build: {unknown: true, steps: [{argv: [echo]}]}} \ No newline at end of file diff --git a/testdata/ui-compat/unsupported-trigger.yml b/testdata/ui-compat/unsupported-trigger.yml new file mode 100644 index 0000000..da4e5f0 --- /dev/null +++ b/testdata/ui-compat/unsupported-trigger.yml @@ -0,0 +1,5 @@ +on: {schedule: [{cron: "* * * * *"}]} +jobs: + build: + steps: + - argv: [echo, ok] diff --git a/ui/README.md b/ui/README.md new file mode 100644 index 0000000..ea06dc9 --- /dev/null +++ b/ui/README.md @@ -0,0 +1,226 @@ +# Workflow UI + +`@agent-workflow/ui` embeds this repository's workflow definition authoring in a host application. It includes a job graph, step navigation, property forms, and synchronized YAML. Persistence, execution, filesystem resolution, credentials, and agent availability belong to the host. + +Development requires Node 24. React exports support React 18 and 19. The browser targets are modern Chrome, Firefox, and Safari. The package is independent of the Go build. It is prepared for packing locally; it has not been published to a registry. + +## Install and embed + +Build and pack from this repository: + +```sh +make ui-install ui-check ui-test ui-build +cd ui && npm pack +# In your application: +npm install /path/to/agent-workflow-ui-0.1.0.tgz +``` + +React applications import the styles once and give the component a container with a height: + +```tsx +import { WorkflowEditor, WorkflowViewer } from '@agent-workflow/ui'; +import '@agent-workflow/ui/style.css'; + +
+ setYamlSource(snapshot.source)} + onSave={async snapshot => persist(snapshot.source)} + /> +
+``` + +`source` is the initial document and accepts subsequent host replacements. The editor keeps its own draft between host updates; an unchanged source prop does not reset the draft. `onChange` runs after edits and validation/save state changes. Inline React callbacks and catalogs are supported. Use `validationKey` to rerun validation when external policy or host context changes without a source edit. React and ReactDOM are peers and are external in the ES module build. + +Plain JavaScript module consumers use: + +```js +import { mountWorkflowEditor, mountWorkflowViewer } from '@agent-workflow/ui'; +import '@agent-workflow/ui/style.css'; + +const editor = mountWorkflowEditor(document.getElementById('workflow'), { + source: yamlSource, + onChange: snapshot => console.log(snapshot.diagnostics), +}); +editor.update({ theme: 'dark' }); +const draft = editor.getSnapshot(); +editor.destroy(); +``` + +`update` merges options. Host document replacements participate in the same undo history. `getSnapshot` returns an independent copy. `destroy` unmounts React, destroys CodeMirror, cleans up custom forms, and aborts pending validation. Each mount has its own document, selection, history, layout, and viewport. + +For a framework-free HTML page, copy `dist/standalone.js` and `dist/style.css` to your application's static assets: + +```html + +
+ + +``` + +The IIFE includes React and the canvas/editor runtime. It needs no global framework installation. Both bundles use local styles and assets and make no provider, execution, or persistence requests. + +## Snapshots, validation, and saving + +A snapshot contains `source`, `workflow` when the current structure is usable, `previewWorkflow` for the last usable graph, `stale`, `diagnostics`, `revision`, history availability, and asynchronous save/validation state. The source remains authoritative. Opening/exporting without changes preserves every byte, including line endings. Visual edits use YAML document nodes to preserve comments, mapping order, block prompts, and unrecognized fields; formatting can be normalized when an edit is serialized. Explicitly replacing a structured field replaces that subtree, including its comments. + +Diagnostics identify their `source` (`parser`, `browser`, or `host`), severity, code, document path, and source location where available. Browser parsing enforces one document, the 4 MiB byte limit, bounded nesting, no aliases/merge keys/duplicate keys, field shapes, and unknown fields. Browser admission checks IDs, dependency integrity/cycles, command/action requirements, repeat bounds, and standalone restrictions. Unknown fields remain in the document for round trips. Duplicate dependencies are a browser warning (the runner currently accepts them), and new duplicate connections are rejected. + +The browser does not evaluate expressions or provide a complete runner/runtime admission substitute. It inspects recognized dot-path references in `${{ ... }}` expressions and bare `if`/`repeat.until` conditions, excluding quoted operands. Only fields evaluated by the runner participate; names, callable defaults, and imported metadata remain literal. Bracket/computed paths are not rewritten. Role availability and reusable resolution can use optional `agents` and `workflows` catalogs; these checks are advisory and do not resolve files or nested calls. Structured output contracts need the runner's schema admission (including Go RE2 semantics), which is explicitly marked as a host check. + +```ts +const options = { + source: yamlSource, + agents: [{ id: 'claude', label: 'Claude' }], + workflows: [{ id: 'workflows/classify.yml', label: 'Classifier' }], + validate: async (snapshot, signal) => { + const response = await fetch('/validate', { + method: 'POST', body: snapshot.source, signal, + }); + if (!response.ok) throw new Error('Validation unavailable'); + return response.json(); // Diagnostic[] with source: 'host' + }, + onSave: async snapshot => { + await saveDraft(snapshot.source); + }, +}; +``` + +`validationKey` accepts a string or number identifying the host validation context; changing it reruns validation without editing the source. Superseded validation requests receive an aborted signal and their results are ignored even if the host ignores cancellation. Hook failures become diagnostics or `saveError` and are visible in the editor. Source edits clear old save errors; a failure for an older revision is ignored. Save and export accept drafts with diagnostics. Saving requires an `onSave` hook and is never automatic. Treat submitted sources and host validation as untrusted input on your server. + +Malformed or structurally unusable YAML retains its text and the last graph, marks the preview stale, and suspends visual edits until recovery. Syntax edits, property edits, and graph mutations share undo/redo. Uncommitted text in a form is applied on blur or Enter. YAML supports Mod-Z/Mod-Shift-Z/Mod-Y, search (Mod-F), and expression completion (Ctrl-Space) based on the cursor context. Multiline prompts and commands provide an Insert expression picker; literal metadata fields do not suggest expressions. Save and Mod-S synchronously apply pending built-in inspector fields before invoking optional save. Rejected edits keep their typed values, are marked invalid, and block saving; the first affected field is revealed and focused. Correct the edit or restore its stored value with Escape or Discard edit, then save again. Source documents with diagnostics can still be saved. Graph controls expose zoom and fit; Auto layout explicitly recalculates positions. The dependency checkboxes provide keyboard authoring without canvas gestures. + +Deleting a job or step shows referenced document paths and requires confirmation. Job deletion removes structural dependencies; expression references remain and produce diagnostics. Renaming a job, step, callable input/secret, or job output updates recognized references atomically. Guided interface and output sections also offer identifier fields and deletion-impact review. Before reordering, duplicating, deleting, or appending steps, existing unnamed steps receive their current `step_N` IDs. New steps get explicit unique IDs. + +`WorkflowViewer` and `mountWorkflowViewer` retain navigation, source search, selection, expansion, zoom, and inspection while disabling document editing. Source can still be replaced by the host through `update`. + +Selecting a job or step in the graph, navigation, or inspector reveals and selects its definition in the YAML source. Focus stays on the selected control. Re-selecting an element reveals it again after manually scrolling the source; opening YAML after a selection reveals that element too. Navigation does not change the document or undo history, and is suspended while the preview is stale. + +## Custom action authoring + +Host definitions change labels and authoring forms; they do not register runner actions or relax runner admission: + +```ts +const actions = [{ + uses: 'agent/claude', + label: 'Reviewer', + fields: [{ key: 'model', label: 'Review model', type: 'text' }], + renderInspector(element, context) { + const button = document.createElement('button'); + button.type = 'button'; + button.textContent = 'Use review prompt'; + button.disabled = context.readOnly; + button.onclick = () => context.setField(['with', 'prompt'], 'Review this task'); + element.append(button); + return () => { button.onclick = null; }; // Called on update/unmount + }, +}]; +``` + +Fields support `text`, `textarea`, `boolean`, and `yaml`. Keys are relative to the step's `with` mapping. A custom renderer gets a copied `step`, the absolute document `path`, `readOnly`, and a guarded `setField` accepting paths relative to that step. Respect `readOnly` in custom controls. Cleanup runs before a replacement render and when unmounted. Host-required field policies belong in additional validation. Built-in forms cover agent prompts/output contracts, direct argv commands, explicit shell commands, all five controller actions, reusable calls, conditions, repeats, and error handling. Guided callable fields cover input types/defaults, required secrets, and outputs; YAML fields expose advanced mappings. + +## Styling and layout persistence + +Set `theme` to `light`, `dark`, or `system` (default). Use the `tokens` option or scoped CSS custom properties: `--aw-bg`, `--aw-panel`, `--aw-text`, `--aw-muted`, `--aw-border`, `--aw-accent`, `--aw-focus`, `--aw-error`, `--aw-radius`, `--aw-syntax-key`, `--aw-syntax-string`, and `--aw-syntax-literal`. Token overrides belong in `tokens` because theme defaults are defined on the editor root. Styles are scoped to `.aw-editor`; React Flow also supplies its standard `.react-flow` styles. Focus indicators and reduced-motion preferences are supported. Container queries turn the side panels into drawers below 760px, independently for each instance. Narrow containers initially show Visual mode, with YAML and Split available; hidden panels are excluded from keyboard focus. Canvas resizing refits the graph, and new jobs avoid existing positions. + +`layout` and `onLayoutChange` carry `{ positions, collapsed, viewport }` outside the workflow YAML. Store them separately, keyed by the host's document ID. Positions use job IDs as keys. Initial layout is automatic when positions are missing. Renames carry layout entries to the new ID; undo restores the document without rolling back navigation metadata. + +```ts +mountWorkflowEditor(element, { + source, + layout: savedLayout, + onLayoutChange: layout => persistLayout(documentID, layout), +}); +``` + +## Development and verification + +```sh +make ui-install +npm exec --prefix ui -- playwright install --with-deps chromium firefox webkit +make ui-check ui-test ui-browser-test +make test check +npm run dev --prefix ui +``` + +The playground loads all four repository examples and saves only layout to local storage. Browser tests exercise module embedding under React 19/StrictMode, plain mounts, read-only controls, custom renderer cleanup, keyboard dependencies, independent instances, themes, and container resizing, real canvas gestures, controlled hosts, host form embedding, and WCAG contrast/focus checks. Packed consumer fixtures build a React 18 application and a standalone HTML page, then exercise them in Chromium, Firefox, and WebKit. Shared `testdata/ui-compat` cases run through JavaScript checks and Go `Parse`/`Validate`. `Workflow UI` is included in CI's required aggregate job. All these checks are deterministic and use no live providers or GitHub publication. + +CI runs Chromium, Firefox, and WebKit in parallel jobs using the official +Playwright image with browsers and system libraries preinstalled. This avoids +installing browser dependencies from Ubuntu mirrors on every run. Keep the image +version in `.github/workflows/test.yml` aligned with `@playwright/test` in +`ui/package-lock.json` when updating Playwright. Every browser job runs the flow +gate, unit and type checks, builds the package and consumer fixtures, and runs its +complete browser project; the required aggregate waits for all three. + +## Required flow gate + +Every PR, including an unrelated PR, must pass this local command before it is +ready and again after relevant edits: + +```sh +make ui-install +cd ui && npx playwright install --with-deps chromium +cd .. +make ui-pr-check BASE=origin/main +``` + +Use the PR target branch for `BASE`. The checker compares its merge base with +HEAD against the complete working tree (committed, staged, unstaged, and +untracked inputs). If history is missing it prints the exact fetch and rerun +commands; it never skips pairing silently. For discovery and synchronization +without launching browsers, run `make ui-flows-check BASE=origin/main`. +`make ui-flow-checker-test` runs disposable Git fixture tests of the gate itself. + +The [user flows](../docs/UI_USER_FLOWS.md) each link to their dedicated +`test/browser/flows/uf-NN.spec.ts`. IDs stay stable when flows are added or +removed. Tests interact with visible controls; host APIs set up fixtures and +inspect outcomes. The gate discovers cases through Playwright without launching +a browser. It rejects missing/duplicate/malformed IDs, missing/orphan specs, +broken links, focused tests, and skipped or expected-failure declarations. +At runtime every selected case must pass and execute a successful assertion in +its test body; an empty case or assertions only in setup hooks cannot cover a flow. + +A changed UF section requires a substantive change to its owning test. A changed +test requires a substantive change to that UF section. Shared test imports are +followed in both revisions so removed imports and deleted helpers still count. +The browser HTML/TSX harness conservatively affects every harness flow. A helper +change can satisfy the test side of pairing, but every affected UF still needs +a prose update. Formatting, comments, and coverage-link edits do not count. +When behavior has not changed, revise the short **Coverage** note to explain the +new assertions or recovery scenario. Do not use timestamps, checksums, or empty +acknowledgments. Failures name the UF and title, changed file/section, missing +counterpart, repair action, and rerun command; shared helper failures identify +the affected group. CI emits the same findings as annotations and job summaries. + +Browser selection is conservative: + +- UF/spec changes select those flows; shared helper or fixture changes select + their affected flows. +- UI source, styles, playground, examples, dependencies, and browser/build/gate + configuration select all flows. +- Unrelated changes still check all mappings but start no browser. + +The local run uses Chromium, two workers, no retries, and automatic waiting. +It needs neither packed-consumer artifacts nor package builds or provider +credentials. Each Workflow UI CI job runs the structural gate before builds and +browser tests. PR pairing uses the actual PR base SHA with full Git +history. Push and reusable-workflow runs validate structure and run all flows. +The existing Chromium/Firefox/WebKit suite executes the UF specs once alongside +technical embedding and packed-consumer checks, retaining traces and uploading +browser reports when it fails. Passing the gate establishes traceability and +executed assertions; reviewers still assess whether each test represents its +flow. The seven separately tracked UX issues remain outside this coverage work. + +With installed dependencies and browsers on the implementation machine (Node 24, +two workers), structural validation took about 1 second; all 65 UF cases in +Chromium took 14.9 seconds, and the complete `make ui-pr-check` command including +checker self-tests took 23.7 seconds. These are local measurements, with targets +of roughly 2 seconds for structure and under 30 seconds for the full local run. +Unrelated-change fixtures verified that the command starts no browser. diff --git a/ui/consumers/react/index.html b/ui/consumers/react/index.html new file mode 100644 index 0000000..39b7b52 --- /dev/null +++ b/ui/consumers/react/index.html @@ -0,0 +1 @@ +React package consumer
diff --git a/ui/consumers/react/main.tsx b/ui/consumers/react/main.tsx new file mode 100644 index 0000000..5b0c47c --- /dev/null +++ b/ui/consumers/react/main.tsx @@ -0,0 +1,9 @@ +import React, { useState } from 'react'; +import { createRoot } from 'react-dom/client'; +import { WorkflowEditor } from '@agent-workflow/ui'; +import '@agent-workflow/ui/style.css'; +function App() { + const [source, setSource] = useState('jobs: {packed: {steps: [{argv: [echo, packed]}]}}'); + return setSource(snapshot.source)} agents={[{ id: 'claude' }]} validate={async () => []} />; +} +createRoot(document.getElementById('root')!).render(); diff --git a/ui/consumers/react/package-lock.json b/ui/consumers/react/package-lock.json new file mode 100644 index 0000000..2178ad0 --- /dev/null +++ b/ui/consumers/react/package-lock.json @@ -0,0 +1,515 @@ +{ + "name": "workflow-react-consumer", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "workflow-react-consumer", + "dependencies": { + "@agent-workflow/ui": "file:../../.consumer-artifacts/agent-workflow-ui-0.1.0.tgz", + "react": "18.3.1", + "react-dom": "18.3.1" + } + }, + "node_modules/@agent-workflow/ui": { + "version": "0.1.0", + "resolved": "file:../../.consumer-artifacts/agent-workflow-ui-0.1.0.tgz", + "integrity": "sha512-bTjwe8Uyu7StnPjq6tV9ZCn6umUFwt/1Y2Opo5+aXYF/pzARLJkPGgkI+r5LrAnq8PiCfcwFrmuJhVLYkZICxw==", + "dependencies": { + "@codemirror/autocomplete": "^6.20.0", + "@codemirror/commands": "^6.10.0", + "@codemirror/lang-yaml": "^6.1.0", + "@codemirror/language": "^6.13.1", + "@codemirror/search": "^6.7.2", + "@codemirror/state": "^6.5.0", + "@codemirror/view": "^6.38.0", + "@dagrejs/dagre": "^1.1.5", + "@lezer/highlight": "^1.2.5", + "@xyflow/react": "^12.8.0", + "yaml": "^2.8.0" + }, + "engines": { + "node": ">=24" + }, + "peerDependencies": { + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + } + }, + "node_modules/@codemirror/autocomplete": { + "version": "6.20.3", + "resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.20.3.tgz", + "integrity": "sha512-tlosUqb+3BbxCxZdu4tKeRghPFC+QM7q4X5YhKV2eCmPG+1r2F3f4AaSz5sCrFqUtX4Jh20VFTKecl16MgiV9g==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.17.0", + "@lezer/common": "^1.0.0" + } + }, + "node_modules/@codemirror/commands": { + "version": "6.11.1", + "resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.11.1.tgz", + "integrity": "sha512-O/4hG3SC1YwcmQ0d2UVNDs+AsaNWd1iHVxbTeEBuqH+6bExAiPK3iS/BvpY6rZGURALv4ZD3sIgcCmRvw3ehBg==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.7.0", + "@codemirror/view": "^6.27.0", + "@lezer/common": "^1.1.0" + } + }, + "node_modules/@codemirror/lang-yaml": { + "version": "6.1.3", + "resolved": "https://registry.npmjs.org/@codemirror/lang-yaml/-/lang-yaml-6.1.3.tgz", + "integrity": "sha512-AZ8DJBuXGVHybpBQhmZtgew5//4hv3tdkXnr3vDmOUMJRuB6vn/uuwtmTOTlqEaQFg3hQSVeA90NmvIQyUV6FQ==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.2.0", + "@lezer/lr": "^1.0.0", + "@lezer/yaml": "^1.0.0" + } + }, + "node_modules/@codemirror/language": { + "version": "6.13.1", + "resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.13.1.tgz", + "integrity": "sha512-zfvU2r3TyylQLMj/te+5CnQ0rSqZi8B1oTCBbQkagcLW4Xc6imab6qN+pEJhsuNOLaJqiRyBXp9UhWA1DhVvDw==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.0.0", + "@codemirror/streamparser": "^6.0.0", + "@codemirror/view": "^6.23.0", + "@lezer/common": "^1.5.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0", + "style-mod": "^4.0.0" + } + }, + "node_modules/@codemirror/search": { + "version": "6.7.2", + "resolved": "https://registry.npmjs.org/@codemirror/search/-/search-6.7.2.tgz", + "integrity": "sha512-gUYkYhT2+n/+VGZ+8EzE5WFkYZUZYm1VOKDudIsNqh42uRVQJ0a6Yss9sdKT3MeOYfuL1N6AZA57oza0Oyr0LA==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.37.0", + "crelt": "^1.0.5" + } + }, + "node_modules/@codemirror/state": { + "version": "6.7.6", + "resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.7.6.tgz", + "integrity": "sha512-kAz+AncRtKuIknedxT1bq4XwXv4UowhbkHU1myPrtVb/jZtImWuV5BXzv5vK6i3kYACsdiZiQKFQQ5Mq7elW8w==", + "license": "MIT", + "dependencies": { + "@marijn/find-cluster-break": "^1.0.0" + } + }, + "node_modules/@codemirror/streamparser": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@codemirror/streamparser/-/streamparser-6.0.0.tgz", + "integrity": "sha512-JTFPF6xrfjt0mnfGPgzcLiMwYmvIyNaoizp8BYzv8FE8KrT6GBGDZ190rtt+O7Wke3gG/MFHyiY9YGNeimof4w==", + "license": "MIT" + }, + "node_modules/@codemirror/view": { + "version": "6.43.14", + "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.43.14.tgz", + "integrity": "sha512-hC45YnmJNR1rz42OF57BLoQNFMV12/13drnvibDsR2quNDmldcb4WvYVV81hFQg56i9lcqQMEDqilhX2QDcGQQ==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.7.0", + "crelt": "^1.0.6", + "style-mod": "^4.1.0", + "w3c-keyname": "^2.2.4" + } + }, + "node_modules/@dagrejs/dagre": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/@dagrejs/dagre/-/dagre-1.1.8.tgz", + "integrity": "sha512-5SEDlndt4W/LaVzPYJW+bSmSEZc9EzTf8rJ20WCKvjS5EAZAN0b+x0Yww7VMT4R3Wootkg+X9bUfUxazYw6Blw==", + "license": "MIT", + "dependencies": { + "@dagrejs/graphlib": "2.2.4" + } + }, + "node_modules/@dagrejs/graphlib": { + "version": "2.2.4", + "resolved": "https://registry.npmjs.org/@dagrejs/graphlib/-/graphlib-2.2.4.tgz", + "integrity": "sha512-mepCf/e9+SKYy1d02/UkvSy6+6MoyXhVxP8lLDfA7BPE1X1d4dR0sZznmbM8/XVJ1GPM+Svnx7Xj6ZweByWUkw==", + "license": "MIT", + "engines": { + "node": ">17.0.0" + } + }, + "node_modules/@lezer/common": { + "version": "1.5.3", + "resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.5.3.tgz", + "integrity": "sha512-H0iErY4e43LpXbYDyBci5W4v/RwTgGV3YzYOlJPiGZ8RY8w51kVE+xLn1tg3Z6UmSfyETvtsUK3r7YsgDQEI7Q==", + "license": "MIT" + }, + "node_modules/@lezer/highlight": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.5.tgz", + "integrity": "sha512-O1GMVKgtf5YspFaRzpmqkVgMtIk2HG9uMAbH6wdAIdNh3TVzCsch/+cRi+6l2//UGDKe6NB9BW3cvuR5ezMQ0w==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.3.0" + } + }, + "node_modules/@lezer/lr": { + "version": "1.4.11", + "resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.11.tgz", + "integrity": "sha512-UwgZcl1R66H2hDzBrA7kWyYmX6s8p5TQ+e/t7CuvgCtBEPxbEPaHYHu+pJBvTvDotEJpuQkJjbXuHiw5Cixxgw==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.0.0" + } + }, + "node_modules/@lezer/yaml": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@lezer/yaml/-/yaml-1.0.4.tgz", + "integrity": "sha512-2lrrHqxalACEbxIbsjhqGpSW8kWpUKuY6RHgnSAFZa6qK62wvnPxA8hGOwOoDbwHcOFs5M4o27mjGu+P7TvBmw==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.4.0" + } + }, + "node_modules/@marijn/find-cluster-break": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.4.tgz", + "integrity": "sha512-Wy0V7+SGUjnF9/TkiM1hKVDPj7jKXduPNboMVtHTA8dySMURWqfg/JZ9E2Sq8JgSJmkl7k7Qe9FLeMSrSraWmQ==", + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-drag": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz", + "integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-selection": { + "version": "3.0.12", + "resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.12.tgz", + "integrity": "sha512-Qe/KWYhEiIIxGs7HrAAjMfShxKldx19SJtr5zu53f3afPsdZNz7HHtdTLXo/kqeiWNXVycI24kSnfzBYkTzpgw==", + "license": "MIT" + }, + "node_modules/@types/d3-transition": { + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz", + "integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-zoom": { + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.9.tgz", + "integrity": "sha512-0sE1406XBYJGiqD3AusTl9ZqC//2mIXix51tbom25gDCA8ri4xnSZg28CaSE8Srl6FClqABUKfsn0qghgdepMA==", + "license": "MIT", + "dependencies": { + "@types/d3-interpolate": "*", + "@types/d3-selection": "*" + } + }, + "node_modules/@xyflow/react": { + "version": "12.12.0", + "resolved": "https://registry.npmjs.org/@xyflow/react/-/react-12.12.0.tgz", + "integrity": "sha512-74oXI0Rgm1Eu33cHQAd74J6oVgCH9mbFyTVQbsGgp2kmWwh0HdtBr2VP04z8mbPD+M/xO69nqG5dDEt7/EN2rQ==", + "license": "MIT", + "dependencies": { + "@xyflow/system": "0.0.83", + "classcat": "^5.0.3", + "zustand": "^4.4.0" + }, + "peerDependencies": { + "@types/react": ">=17", + "@types/react-dom": ">=17", + "react": ">=17", + "react-dom": ">=17" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@xyflow/system": { + "version": "0.0.83", + "resolved": "https://registry.npmjs.org/@xyflow/system/-/system-0.0.83.tgz", + "integrity": "sha512-a//eDjez3WZF47aaPTfVUDD0OINOq+WVwBZCRTqG+m0a5euX8EwlJ4jMY1Wxz0Ocj4xOeekJ70V22Pq0ll8RJw==", + "license": "MIT", + "dependencies": { + "@types/d3-drag": "^3.0.7", + "@types/d3-interpolate": "^3.0.4", + "@types/d3-selection": "^3.0.10", + "@types/d3-transition": "^3.0.8", + "@types/d3-zoom": "^3.0.8", + "d3-drag": "^3.0.0", + "d3-interpolate": "^3.0.1", + "d3-selection": "^3.0.0", + "d3-zoom": "^3.0.0" + } + }, + "node_modules/classcat": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/classcat/-/classcat-5.0.5.tgz", + "integrity": "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==", + "license": "MIT" + }, + "node_modules/crelt": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.7.tgz", + "integrity": "sha512-aK6BbWfhf4U/wCcLHKPJl/xa6VkVstRaPywWtMKGwuOLc/wZTyQYuoxgvZnNsBvv7Kg3YTBQYYBCggcviQczuA==", + "license": "MIT" + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dispatch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz", + "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-drag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz", + "integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-selection": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-selection": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", + "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-transition": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz", + "integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3", + "d3-dispatch": "1 - 3", + "d3-ease": "1 - 3", + "d3-interpolate": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "d3-selection": "2 - 3" + } + }, + "node_modules/d3-zoom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz", + "integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "2 - 3", + "d3-transition": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/style-mod": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.4.tgz", + "integrity": "sha512-XXWIQt633/EpAFx8aZDOTjBzrCaGmhvEQlQo6MVPfa2OzO2cWo+4hV9h+6UkHYlXGfy+ODXKUdP7Pthmcu5ATw==", + "license": "MIT" + }, + "node_modules/use-sync-external-store": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.7.0.tgz", + "integrity": "sha512-6L+EeigHMQhdaIPNIFUKwfWJSwWFQ8gJbJ2DLOs5sDIegTwR9fRxvnM3uciHKjIZhFz+KAv2emhWMRvDmMcY8A==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/w3c-keyname": { + "version": "2.2.8", + "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", + "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==", + "license": "MIT" + }, + "node_modules/yaml": { + "version": "2.9.1", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.1.tgz", + "integrity": "sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw==", + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, + "node_modules/zustand": { + "version": "4.5.7", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz", + "integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==", + "license": "MIT", + "dependencies": { + "use-sync-external-store": "^1.2.2" + }, + "engines": { + "node": ">=12.7.0" + }, + "peerDependencies": { + "@types/react": ">=16.8", + "immer": ">=9.0.6", + "react": ">=16.8" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + } + } + } + } +} diff --git a/ui/consumers/react/package.json b/ui/consumers/react/package.json new file mode 100644 index 0000000..e8b475a --- /dev/null +++ b/ui/consumers/react/package.json @@ -0,0 +1 @@ +{"name":"workflow-react-consumer","private":true,"type":"module","dependencies":{"@agent-workflow/ui":"file:../../.consumer-artifacts/agent-workflow-ui-0.1.0.tgz","react":"18.3.1","react-dom":"18.3.1"}} diff --git a/ui/consumers/standalone/index.html b/ui/consumers/standalone/index.html new file mode 100644 index 0000000..ca4a9b6 --- /dev/null +++ b/ui/consumers/standalone/index.html @@ -0,0 +1 @@ +Standalone consumer
diff --git a/ui/index.html b/ui/index.html new file mode 100644 index 0000000..d0d7975 --- /dev/null +++ b/ui/index.html @@ -0,0 +1 @@ +Workflow editor playground

Workflow playground

diff --git a/ui/package-lock.json b/ui/package-lock.json new file mode 100644 index 0000000..044065a --- /dev/null +++ b/ui/package-lock.json @@ -0,0 +1,2674 @@ +{ + "name": "@agent-workflow/ui", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@agent-workflow/ui", + "version": "0.1.0", + "dependencies": { + "@codemirror/autocomplete": "^6.20.0", + "@codemirror/commands": "^6.10.0", + "@codemirror/lang-yaml": "^6.1.0", + "@codemirror/language": "^6.13.1", + "@codemirror/search": "^6.7.2", + "@codemirror/state": "^6.5.0", + "@codemirror/view": "^6.38.0", + "@dagrejs/dagre": "^1.1.5", + "@lezer/highlight": "^1.2.5", + "@xyflow/react": "^12.8.0", + "yaml": "^2.8.0" + }, + "devDependencies": { + "@axe-core/playwright": "^4.14.0", + "@playwright/test": "^1.56.0", + "@types/dagre": "^0.7.53", + "@types/node": "^24.19.2", + "@types/react": "^19.1.0", + "@types/react-dom": "^19.1.0", + "@vitejs/plugin-react": "^5.0.0", + "react": "^19.1.0", + "react-dom": "^19.1.0", + "typescript": "^5.9.0", + "vite": "^7.1.0", + "vitest": "^5.0.5" + }, + "engines": { + "node": ">=24" + }, + "peerDependencies": { + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + } + }, + "node_modules/@axe-core/playwright": { + "version": "4.14.0", + "resolved": "https://registry.npmjs.org/@axe-core/playwright/-/playwright-4.14.0.tgz", + "integrity": "sha512-SGUhG5PIHd2eMdpgBF2PyQLOhX+qyDR2PT4cmoS4Qk2ak+jX/rrSvst9l0PGv5WTHolKpUMvVmoHpr4cZgf1RQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "axe-core": "~4.14.0" + }, + "engines": { + "node": ">=22.12.0" + }, + "peerDependencies": { + "playwright-core": ">= 1.0.0" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz", + "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.8", + "@babel/types": "^7.29.8", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.10", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.10.tgz", + "integrity": "sha512-EMCx3AGZ3WfAl6ht7AyQoPm2Lh+BGYEMdc4Q6Hf7uSCDOZFuLq44qNwL+iaim8VlyYhRAo4X43+weadK8ZB4Yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.8" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.9", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.9.tgz", + "integrity": "sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz", + "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz", + "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.10", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.10.tgz", + "integrity": "sha512-B3lYAs2nkKskbgQ1nfy8Ma9ao9Q1rntTAQHrzzyDuj+TSO+X3le6PfWwqDvodsb+OumE10eMFfuMCTQSQ5iuIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.8", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.9", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.8", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@codemirror/autocomplete": { + "version": "6.20.3", + "resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.20.3.tgz", + "integrity": "sha512-tlosUqb+3BbxCxZdu4tKeRghPFC+QM7q4X5YhKV2eCmPG+1r2F3f4AaSz5sCrFqUtX4Jh20VFTKecl16MgiV9g==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.17.0", + "@lezer/common": "^1.0.0" + } + }, + "node_modules/@codemirror/commands": { + "version": "6.11.1", + "resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.11.1.tgz", + "integrity": "sha512-O/4hG3SC1YwcmQ0d2UVNDs+AsaNWd1iHVxbTeEBuqH+6bExAiPK3iS/BvpY6rZGURALv4ZD3sIgcCmRvw3ehBg==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.7.0", + "@codemirror/view": "^6.27.0", + "@lezer/common": "^1.1.0" + } + }, + "node_modules/@codemirror/lang-yaml": { + "version": "6.1.3", + "resolved": "https://registry.npmjs.org/@codemirror/lang-yaml/-/lang-yaml-6.1.3.tgz", + "integrity": "sha512-AZ8DJBuXGVHybpBQhmZtgew5//4hv3tdkXnr3vDmOUMJRuB6vn/uuwtmTOTlqEaQFg3hQSVeA90NmvIQyUV6FQ==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.2.0", + "@lezer/lr": "^1.0.0", + "@lezer/yaml": "^1.0.0" + } + }, + "node_modules/@codemirror/language": { + "version": "6.13.1", + "resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.13.1.tgz", + "integrity": "sha512-zfvU2r3TyylQLMj/te+5CnQ0rSqZi8B1oTCBbQkagcLW4Xc6imab6qN+pEJhsuNOLaJqiRyBXp9UhWA1DhVvDw==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.0.0", + "@codemirror/streamparser": "^6.0.0", + "@codemirror/view": "^6.23.0", + "@lezer/common": "^1.5.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0", + "style-mod": "^4.0.0" + } + }, + "node_modules/@codemirror/search": { + "version": "6.7.2", + "resolved": "https://registry.npmjs.org/@codemirror/search/-/search-6.7.2.tgz", + "integrity": "sha512-gUYkYhT2+n/+VGZ+8EzE5WFkYZUZYm1VOKDudIsNqh42uRVQJ0a6Yss9sdKT3MeOYfuL1N6AZA57oza0Oyr0LA==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.37.0", + "crelt": "^1.0.5" + } + }, + "node_modules/@codemirror/state": { + "version": "6.7.6", + "resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.7.6.tgz", + "integrity": "sha512-kAz+AncRtKuIknedxT1bq4XwXv4UowhbkHU1myPrtVb/jZtImWuV5BXzv5vK6i3kYACsdiZiQKFQQ5Mq7elW8w==", + "license": "MIT", + "dependencies": { + "@marijn/find-cluster-break": "^1.0.0" + } + }, + "node_modules/@codemirror/streamparser": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@codemirror/streamparser/-/streamparser-6.0.0.tgz", + "integrity": "sha512-JTFPF6xrfjt0mnfGPgzcLiMwYmvIyNaoizp8BYzv8FE8KrT6GBGDZ190rtt+O7Wke3gG/MFHyiY9YGNeimof4w==", + "license": "MIT" + }, + "node_modules/@codemirror/view": { + "version": "6.43.14", + "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.43.14.tgz", + "integrity": "sha512-hC45YnmJNR1rz42OF57BLoQNFMV12/13drnvibDsR2quNDmldcb4WvYVV81hFQg56i9lcqQMEDqilhX2QDcGQQ==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.7.0", + "crelt": "^1.0.6", + "style-mod": "^4.1.0", + "w3c-keyname": "^2.2.4" + } + }, + "node_modules/@dagrejs/dagre": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/@dagrejs/dagre/-/dagre-1.1.8.tgz", + "integrity": "sha512-5SEDlndt4W/LaVzPYJW+bSmSEZc9EzTf8rJ20WCKvjS5EAZAN0b+x0Yww7VMT4R3Wootkg+X9bUfUxazYw6Blw==", + "license": "MIT", + "dependencies": { + "@dagrejs/graphlib": "2.2.4" + } + }, + "node_modules/@dagrejs/graphlib": { + "version": "2.2.4", + "resolved": "https://registry.npmjs.org/@dagrejs/graphlib/-/graphlib-2.2.4.tgz", + "integrity": "sha512-mepCf/e9+SKYy1d02/UkvSy6+6MoyXhVxP8lLDfA7BPE1X1d4dR0sZznmbM8/XVJ1GPM+Svnx7Xj6ZweByWUkw==", + "license": "MIT", + "engines": { + "node": ">17.0.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@lezer/common": { + "version": "1.5.3", + "resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.5.3.tgz", + "integrity": "sha512-H0iErY4e43LpXbYDyBci5W4v/RwTgGV3YzYOlJPiGZ8RY8w51kVE+xLn1tg3Z6UmSfyETvtsUK3r7YsgDQEI7Q==", + "license": "MIT" + }, + "node_modules/@lezer/highlight": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.5.tgz", + "integrity": "sha512-O1GMVKgtf5YspFaRzpmqkVgMtIk2HG9uMAbH6wdAIdNh3TVzCsch/+cRi+6l2//UGDKe6NB9BW3cvuR5ezMQ0w==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.3.0" + } + }, + "node_modules/@lezer/lr": { + "version": "1.4.11", + "resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.11.tgz", + "integrity": "sha512-UwgZcl1R66H2hDzBrA7kWyYmX6s8p5TQ+e/t7CuvgCtBEPxbEPaHYHu+pJBvTvDotEJpuQkJjbXuHiw5Cixxgw==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.0.0" + } + }, + "node_modules/@lezer/yaml": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@lezer/yaml/-/yaml-1.0.4.tgz", + "integrity": "sha512-2lrrHqxalACEbxIbsjhqGpSW8kWpUKuY6RHgnSAFZa6qK62wvnPxA8hGOwOoDbwHcOFs5M4o27mjGu+P7TvBmw==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.4.0" + } + }, + "node_modules/@marijn/find-cluster-break": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.4.tgz", + "integrity": "sha512-Wy0V7+SGUjnF9/TkiM1hKVDPj7jKXduPNboMVtHTA8dySMURWqfg/JZ9E2Sq8JgSJmkl7k7Qe9FLeMSrSraWmQ==", + "license": "MIT" + }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@playwright/test": { + "version": "1.64.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.64.1.tgz", + "integrity": "sha512-IaYGml/nSOiZWROfPtuIL7neuu9qDOZ3TNvNljUIrhDMxiGTgPjzj/g9p9RxpS2tSepPDRY+UwAaEeiKtUvIAg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.64.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.3", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz", + "integrity": "sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.64.7", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.64.7.tgz", + "integrity": "sha512-GFF2tHk0NqYVObrK0+/ps/LEXsV/v2q6Lyiy7mZr+Ej24fpQScvqK2ziz9NHeEQYnKWKym38Ml7dZbPtC9tuAw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.64.7", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.64.7.tgz", + "integrity": "sha512-BOymcXJFYYXqluDidj6sLelNjXruC3u1BqVxLn2OPCXH9xaPSOTH+MS4uychsGI1NU7tJoiv9KvqijDHFe+hhQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.64.7", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.64.7.tgz", + "integrity": "sha512-jgVpGweIbls2H5jfBB70tnX52abMd5pJr3xmtoDILx6JdmGl+Un+tMb9R+MbHPVB46tTBpY8emtn7M71SnQhEQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.64.7", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.64.7.tgz", + "integrity": "sha512-AVMpt94mrXH7WjyWKmd9nT4RC5yM0AN3Lt6A89xVIsuLDi3dDX6dxMytLbW36FlppY9JsoqUoS3LUUpUrYxiMQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.64.7", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.64.7.tgz", + "integrity": "sha512-Nje29c/MyajxRNFGU05oyeTDS6SWdMg/wWMSlZSBTBju5BKz2NVc0kFiFSQGzRELNiB+CBk/ZvAVsWsENgjGag==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.64.7", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.64.7.tgz", + "integrity": "sha512-xxgN5/kMzCNz/NYJ4t49B0/WS6InNlWv6xvgwUQqNnWM9WJMMwMUGWrAxdNEgYWdHL0sMnuh8I87StGrIMTA+w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.64.7", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.64.7.tgz", + "integrity": "sha512-5U/9gVn/X657I8+NHoHO9Wnfyd0lEXwqbslobRoB9TC8SwYzOitcIr6TLE7531Q/NWs3L4mb+YOGzlf3vHIeYw==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.64.7", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.64.7.tgz", + "integrity": "sha512-hcDS0a/Y8ggM3dcOfzH7UpSaogCViWTtNDSl0EXpWIj8g8/r86ITqptjil2hayTXS0Umzb+Yt3mjgpQVFVf2+A==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.64.7", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.64.7.tgz", + "integrity": "sha512-awbAr4N7Qd/yjZAQBEEPzgcPveYG2Gi5L6gq1/Bp+3SMRjn5F68Wv1zKsntzvvoioBR6yKvhLtWqu11am1TsxA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.64.7", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.64.7.tgz", + "integrity": "sha512-Ut7xQJfeLhzu2yN3rHzVvL9ccyPJvN4aCdP1V7hJ5lGxX/eCzgB9SiMk/Md3J+rWWZgvoYsDW4BUFggJr32pBA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.64.7", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.64.7.tgz", + "integrity": "sha512-trqoni/vHkuM5oo5iQhoKPh6fjjD7E80pMgCXWgGxU8vSRPUlwvG8GeYvEEcB6eJAxNa99WH9iVHPHelgtKiHA==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.64.7", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.64.7.tgz", + "integrity": "sha512-gvlUy0XFIwtUWiQmhc+4GT2Knm3KosinpUVhr7CtTFA78gFDnE5NWwMPzHrJsYpKkh2LlY7yvBIboN1FAV6Xeg==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.64.7", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.64.7.tgz", + "integrity": "sha512-8yS/kSXTJ/47Z62JW+C2AJ3vd/8zZw016vFjtNpZwmZlwJumLua4s1i78dkYuSSqQ+0aIMxJcBp/4uIwp2ehjA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.64.7", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.64.7.tgz", + "integrity": "sha512-mxVSKvvty0hOP1AFo/l4Kpv3i3HK0zx4rYUrirZDToYlWhS3xzmIkq3vUJ7rtrM2cPyXveDyDT6M1FfXZ9hTRA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.64.7", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.64.7.tgz", + "integrity": "sha512-lEse1zra1CagM+uHH2bl28GInDsZZ45NJzHei2OMaXQFzZWKBO1hFo07rS0xB6ayV4WU360UrSmPDSyCMpYsUQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.64.7", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.64.7.tgz", + "integrity": "sha512-d/cqCoiG9WMZ5VuJZ5Qdlv9g97QyoGwnmvHccvGhKSacTy7xFrAS+jMcJSBdMNOw1oacSkl6p7K6O/DRYTKL3g==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.64.7", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.64.7.tgz", + "integrity": "sha512-kVEA//CeDOZZP1XCH2JBgqXb9+ALlwuA/4civaazMVdgzaSQC9WM38w65zG/LLk0lA7F9Od19CSBZW7J9mCbNg==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.64.7", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.64.7.tgz", + "integrity": "sha512-14oLfDsOSr0TSntT/34jRg/V8B2XW/Jomh9zX9CjIM9gwwNlI1OCymS/GVn0DV32h67eRf7U0GGem0rvfXgNwQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.64.7", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.64.7.tgz", + "integrity": "sha512-dFVtPfjZqAnUPpPY4Bfg+87yo1IBW9HiY8iCFtg49Pn46MfZTebSHZh8PL6SOsgyt9LQP8Nj/sE6jm3aWpySmQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.64.7", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.64.7.tgz", + "integrity": "sha512-Dm9CZ2SM3u2NgvBj4awW/gGqkOwzH8f6MSYIJE6+bKcKLI+T0+LhpD6vtetTfrIIx+rLXDf5RJqBP6v7V5O+wg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.64.7", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.64.7.tgz", + "integrity": "sha512-oaxsvS2mL6+78741CT2HCORBvy1USg3Wqpt5qvZwy3aMXRMl4zxyDo8maeFRWJkF3dp0Wbi71fKH5Gr9YI3vYA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.64.7", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.64.7.tgz", + "integrity": "sha512-UKZz1Y4mxUqkWh60uH7B1aZn6UcAimPk4ipRktpb3wfZq7JrzZYyAXTwiafkb3T+kd5P3Sf+vzcRdsDks4WtjQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.64.7", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.64.7.tgz", + "integrity": "sha512-WWkSuC1J+mZJnsDh0mRaCAFSWfQdl6y3fDjZeWElI6zHo8CcOSG5lY3yurKJjEq+FrX0WD60sFrVVpbHqzMmrw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.64.7", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.64.7.tgz", + "integrity": "sha512-u9bp2yXCI3/BUjUxY6uWknAY32pfvvDCPysyUTySotx0dj3ydlFHrRQYfn/b/kZgnfjyEwySMtI7Ga054uiysQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.64.7", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.64.7.tgz", + "integrity": "sha512-EhX6N3XPMEnVU5cRfNgzPOKcSucsbu3nNNWuoAaEuKIqmddBKHN5b9d4rPe65T1I+FaOT176JKiZNsUBp3rqbA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-drag": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz", + "integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-selection": { + "version": "3.0.12", + "resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.12.tgz", + "integrity": "sha512-Qe/KWYhEiIIxGs7HrAAjMfShxKldx19SJtr5zu53f3afPsdZNz7HHtdTLXo/kqeiWNXVycI24kSnfzBYkTzpgw==", + "license": "MIT" + }, + "node_modules/@types/d3-transition": { + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz", + "integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-zoom": { + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.9.tgz", + "integrity": "sha512-0sE1406XBYJGiqD3AusTl9ZqC//2mIXix51tbom25gDCA8ri4xnSZg28CaSE8Srl6FClqABUKfsn0qghgdepMA==", + "license": "MIT", + "dependencies": { + "@types/d3-interpolate": "*", + "@types/d3-selection": "*" + } + }, + "node_modules/@types/dagre": { + "version": "0.7.54", + "resolved": "https://registry.npmjs.org/@types/dagre/-/dagre-0.7.54.tgz", + "integrity": "sha512-QjcRY+adGbYvBFS7cwv5txhVIwX1XXIUswWl+kSQTbI6NjgZydrZkEKX/etzVd7i+bCsCb40Z/xlBY5eoFuvWQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "24.19.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.19.2.tgz", + "integrity": "sha512-93d49wCWJkCyN87MxLpNN6KXPCwFw7bVUg79mGUV1OS40JGWwVCr8n61DXCSqz7CV6/VfPkb7xec7uP2M+QHWQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": ">=7.24.0 <7.24.7" + } + }, + "node_modules/@types/react": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.3.0.tgz", + "integrity": "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.3.0.tgz", + "integrity": "sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==", + "devOptional": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.3.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.2.0.tgz", + "integrity": "sha512-YmKkfhOAi3wsB1PhJq5Scj3GXMn3WvtQ/JC0xoopuHoXSdmtdStOpFrYaT1kie2YgFBcIe64ROzMYRjCrYOdYw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.29.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-rc.3", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.18.0" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/@vitest/mocker": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-5.0.5.tgz", + "integrity": "sha512-xLL3lkDegJZw+MGoGaCCsFayDZthGgpAtr3Vziyfc4p7xH89K7KRPcRBBh/kEYm0Aqkt5DtTmkYpBtRV/FrHZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.31", + "@vitest/spy": "5.0.5", + "estree-walker": "^3.0.3", + "magic-string": "^1.4.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9 || ^3.0.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/spy": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-5.0.5.tgz", + "integrity": "sha512-lwJRExuH417257Q5wv9QYugN9IMJkoxcrKrYxGPQkBzl7wlNpTUxD/5yNX4ESKVUIShKPgkGchYPFMWOOk0wzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@xyflow/react": { + "version": "12.12.0", + "resolved": "https://registry.npmjs.org/@xyflow/react/-/react-12.12.0.tgz", + "integrity": "sha512-74oXI0Rgm1Eu33cHQAd74J6oVgCH9mbFyTVQbsGgp2kmWwh0HdtBr2VP04z8mbPD+M/xO69nqG5dDEt7/EN2rQ==", + "license": "MIT", + "dependencies": { + "@xyflow/system": "0.0.83", + "classcat": "^5.0.3", + "zustand": "^4.4.0" + }, + "peerDependencies": { + "@types/react": ">=17", + "@types/react-dom": ">=17", + "react": ">=17", + "react-dom": ">=17" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@xyflow/system": { + "version": "0.0.83", + "resolved": "https://registry.npmjs.org/@xyflow/system/-/system-0.0.83.tgz", + "integrity": "sha512-a//eDjez3WZF47aaPTfVUDD0OINOq+WVwBZCRTqG+m0a5euX8EwlJ4jMY1Wxz0Ocj4xOeekJ70V22Pq0ll8RJw==", + "license": "MIT", + "dependencies": { + "@types/d3-drag": "^3.0.7", + "@types/d3-interpolate": "^3.0.4", + "@types/d3-selection": "^3.0.10", + "@types/d3-transition": "^3.0.8", + "@types/d3-zoom": "^3.0.8", + "d3-drag": "^3.0.0", + "d3-interpolate": "^3.0.1", + "d3-selection": "^3.0.0", + "d3-zoom": "^3.0.0" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/axe-core": { + "version": "4.14.0", + "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.14.0.tgz", + "integrity": "sha512-9WTZxEjsZ7b13TH8JPmbV2z8CHbl80/2hm3XPEG4JgNdQLK81IBRXmSxHfMAOkSqQeRxT/0dwNDz2GOm3zzpcQ==", + "dev": true, + "license": "MPL-2.0", + "engines": { + "node": ">=4" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.11.29", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.29.tgz", + "integrity": "sha512-YeesxhUmR4h5y5V8KF8iBCqJb8YFrBJ++XBTeGcWIaQOYXGN19JM0FoiOjAhwuDoln8owMgXcWBmgHtYSkky2w==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/browserslist": { + "version": "4.29.3", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.29.3.tgz", + "integrity": "sha512-1R4kiYKXGViqEN0CnoDrXc1StD9niAwu+j2dukWzrD4bJgsD4lDmEp0CRbc6E/vYJIfTHwPmwyaKtVSudICdPA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.11.26", + "caniuse-lite": "^1.0.30001813", + "electron-to-chromium": "^1.5.439", + "node-releases": "^2.0.57", + "update-browserslist-db": "^1.3.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001816", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001816.tgz", + "integrity": "sha512-+8Yx7BXCoqk6IF8yTicjfUR6D/ldJzdlsodlEOiOSPmhK844WIgk67/zIQaGAOxNkZyyvBlrRFnLvqKIoqbAFw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chai": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.3.0.tgz", + "integrity": "sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/classcat": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/classcat/-/classcat-5.0.5.tgz", + "integrity": "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==", + "license": "MIT" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/crelt": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.7.tgz", + "integrity": "sha512-aK6BbWfhf4U/wCcLHKPJl/xa6VkVstRaPywWtMKGwuOLc/wZTyQYuoxgvZnNsBvv7Kg3YTBQYYBCggcviQczuA==", + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dispatch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz", + "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-drag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz", + "integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-selection": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-selection": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", + "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-transition": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz", + "integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3", + "d3-dispatch": "1 - 3", + "d3-ease": "1 - 3", + "d3-interpolate": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "d3-selection": "2 - 3" + } + }, + "node_modules/d3-zoom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz", + "integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "2 - 3", + "d3-transition": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.459", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.459.tgz", + "integrity": "sha512-4SC9HHSFjO/liAhifpdOL5hG9b4BP7/5G3y3UPZVO8trlAXr1CaUEEJL0gWK3kOcYcZrOA5rkwG45Gra/lTIgg==", + "dev": true, + "license": "ISC" + }, + "node_modules/es-module-lexer": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz", + "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/magic-string": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.3.tgz", + "integrity": "sha512-z12OxmaPGE0F4xlpGdjuAUckipLpQlTAuAmyGhNoD7ODpYUzvDfvtxUbjM/jwznyP178oju/KDdyi220wNJ0RQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.6.0" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.20", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz", + "integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.59", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.59.tgz", + "integrity": "sha512-5qr2okC4ItAI0hG5SaV0HoYMpnM/Y6+Oqr7AmPYy/2uEoGvS+GbSKLeRxSS1uAdu59yz0ouzcuXievSgg///WA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/obug": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/obug/-/obug-3.0.0.tgz", + "integrity": "sha512-5vvB5+W7ePv+p3uqxi+RcW1XAzLW0/hxt3/4X4Lc4qHudzOhmBiBwOY6DRob4WnanAEGvNcLjF+KNOufrUoEQw==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/playwright": { + "version": "1.64.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.64.1.tgz", + "integrity": "sha512-HCrVhHr5QQuwYoswEUGTb7gGp522yflbv/ST8nF4sFWGuLjQCt17Q/EubpGGSNKYn1tsTCPZxqHhHCfVURu7UA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.64.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/playwright-core": { + "version": "1.64.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.64.1.tgz", + "integrity": "sha512-BC4s8y6SEVA6Q1rSxZTto8hI1KUI3xXlHGZgvGqP4IqPdI/a6FX9H29IDNz7vKo9l21b6LfYosScPL6M3xxyDQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/postcss": { + "version": "8.5.30", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.30.tgz", + "integrity": "sha512-zkd+e9S1AQHhmaBKye9zlFHWjOwVgqXl1q3ECYy8561K7qxR2cREf/STLskulBFd78k6AOFeA+ff1TtEzk9bAA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.20", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.2" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/react": { + "version": "19.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-19.3.1.tgz", + "integrity": "sha512-QNhWdt7nrgVWWQG58QuGsAR3jv3Hbe4mb4vz7w2csnHavciZJgda1CPNaldF6eVxv90ud3jUXpKlHL69gO38yw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.3.1.tgz", + "integrity": "sha512-Y0bARz53lhEC1NMCASs0/j+faB/zqt3R8hpvg1e8+61ZVJ4UL0UdC4cBbXfZMcIuhrfgeILTLe+ZeBCavQPtKQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.28.0" + }, + "peerDependencies": { + "react": "^19.3.1" + } + }, + "node_modules/react-refresh": { + "version": "0.18.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz", + "integrity": "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.64.7", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.64.7.tgz", + "integrity": "sha512-aCiJONoZp0lNGJ6sYqspLkh6UXaWEey/7Ko2TZyXQ4kgzAhDUxAgbt/oulXKmD+zFHXw1k+RF6ZZWOrjp7DZnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.64.7", + "@rollup/rollup-android-arm64": "4.64.7", + "@rollup/rollup-darwin-arm64": "4.64.7", + "@rollup/rollup-darwin-x64": "4.64.7", + "@rollup/rollup-freebsd-arm64": "4.64.7", + "@rollup/rollup-freebsd-x64": "4.64.7", + "@rollup/rollup-linux-arm-gnueabihf": "4.64.7", + "@rollup/rollup-linux-arm-musleabihf": "4.64.7", + "@rollup/rollup-linux-arm64-gnu": "4.64.7", + "@rollup/rollup-linux-arm64-musl": "4.64.7", + "@rollup/rollup-linux-loong64-gnu": "4.64.7", + "@rollup/rollup-linux-loong64-musl": "4.64.7", + "@rollup/rollup-linux-ppc64-gnu": "4.64.7", + "@rollup/rollup-linux-ppc64-musl": "4.64.7", + "@rollup/rollup-linux-riscv64-gnu": "4.64.7", + "@rollup/rollup-linux-riscv64-musl": "4.64.7", + "@rollup/rollup-linux-s390x-gnu": "4.64.7", + "@rollup/rollup-linux-x64-gnu": "4.64.7", + "@rollup/rollup-linux-x64-musl": "4.64.7", + "@rollup/rollup-openbsd-x64": "4.64.7", + "@rollup/rollup-openharmony-arm64": "4.64.7", + "@rollup/rollup-win32-arm64-msvc": "4.64.7", + "@rollup/rollup-win32-ia32-msvc": "4.64.7", + "@rollup/rollup-win32-x64-gnu": "4.64.7", + "@rollup/rollup-win32-x64-msvc": "4.64.7", + "fsevents": "~2.3.2" + } + }, + "node_modules/scheduler": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.28.0.tgz", + "integrity": "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/source-map-js": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/std-env": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.3.0.tgz", + "integrity": "sha512-OtU/EgQ1kIm5KwqQpBC6ZEMXrZRui11w8zgfTWp8cdO9B8OaPsbA8bTHO2P+HNo1VlUTGMVBwPhydu6poeXiag==", + "dev": true, + "license": "MIT" + }, + "node_modules/style-mod": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.4.tgz", + "integrity": "sha512-XXWIQt633/EpAFx8aZDOTjBzrCaGmhvEQlQo6MVPfa2OzO2cWo+4hV9h+6UkHYlXGfy+ODXKUdP7Pthmcu5ATw==", + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-6.2.2.tgz", + "integrity": "sha512-nIZ2N2dFpjBsg2hgitKu7mA+FvsIJuGJOwysRngmVRRmWgR/UyPjwJj8c/fT/dhm8EkHnF5EOogRf+0Z2qrimA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/tinyexec": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.1.tgz", + "integrity": "sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.24.6", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", + "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", + "dev": true, + "license": "MIT" + }, + "node_modules/update-browserslist-db": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.4.tgz", + "integrity": "sha512-t8wonIOdQyOjtPd2VktlBu6QjTVTks6v8bSi7kd0r2Vll8TK1JYDZtRRdbmCHOAMphkZgrzK+L+vXK4pyZ8t4A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/use-sync-external-store": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.7.0.tgz", + "integrity": "sha512-6L+EeigHMQhdaIPNIFUKwfWJSwWFQ8gJbJ2DLOs5sDIegTwR9fRxvnM3uciHKjIZhFz+KAv2emhWMRvDmMcY8A==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/vite": { + "version": "7.3.7", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.7.tgz", + "integrity": "sha512-AHEzTORMbi7/7aXp7w7KZ5+DCUdSeLsJz0grY8xHpc9DHdYb0vQu6DqJfm2tOw3gDExNgPgnJTXxM6syYFHaCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0 || ^0.28.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitest": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-5.0.5.tgz", + "integrity": "sha512-cPODOMF8YnkiiYtDdHo6n4wWmBrOBBbOIYH2d33M4l3CdNn72h+qMmO6uXffUN6A5AZFXNND/d+krS9mgkXRfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/mocker": "5.0.5", + "chai": "^6.3.0", + "es-module-lexer": "^2.3.2", + "expect-type": "^1.4.0", + "magic-string": "^1.4.3", + "obug": "^3.0.0", + "picomatch": "^4.0.7", + "std-env": "^4.3.0", + "tinybench": "^6.2.2", + "tinyexec": "^1.3.1", + "tinyglobby": "^0.2.17", + "why-is-node-running": "3.2.1" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^22.12.0 || ^24.0.0 || >=26.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "5.0.5", + "@vitest/browser-preview": "5.0.5", + "@vitest/browser-webdriverio": "^5.0.0-beta.5 || >=5.0.0", + "@vitest/coverage-istanbul": "5.0.5", + "@vitest/coverage-v8": "5.0.5", + "@vitest/ui": "5.0.5", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.4.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/w3c-keyname": { + "version": "2.2.8", + "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", + "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==", + "license": "MIT" + }, + "node_modules/why-is-node-running": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-3.2.1.tgz", + "integrity": "sha512-Tb2FUhB4vUsGQlfSquQLYkApkuPAFQXGFzxWKHHumVz2dK+X1RUm/HnID4+TfIGYJ1kTcwOaCk/buYCEJr6YjQ==", + "dev": true, + "license": "MIT", + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=20.11" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/yaml": { + "version": "2.9.1", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.1.tgz", + "integrity": "sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw==", + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, + "node_modules/zustand": { + "version": "4.5.7", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz", + "integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==", + "license": "MIT", + "dependencies": { + "use-sync-external-store": "^1.2.2" + }, + "engines": { + "node": ">=12.7.0" + }, + "peerDependencies": { + "@types/react": ">=16.8", + "immer": ">=9.0.6", + "react": ">=16.8" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + } + } + } + } +} diff --git a/ui/package.json b/ui/package.json new file mode 100644 index 0000000..8c61f90 --- /dev/null +++ b/ui/package.json @@ -0,0 +1,66 @@ +{ + "name": "@agent-workflow/ui", + "version": "0.1.0", + "description": "Embeddable workflow YAML viewer and editor", + "type": "module", + "files": [ + "dist", + "README.md" + ], + "main": "./dist/index.js", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + }, + "./style.css": "./dist/style.css", + "./standalone.js": "./dist/standalone.js" + }, + "sideEffects": [ + "**/*.css" + ], + "engines": { + "node": ">=24" + }, + "peerDependencies": { + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "scripts": { + "dev": "vite --host 127.0.0.1", + "check": "tsc --noEmit", + "test": "vitest run", + "build": "vite build && vite build --config vite.standalone.config.ts && tsc --project tsconfig.build.json", + "test:browser": "playwright test", + "test:consumers": "node scripts/consumers.mjs" + }, + "dependencies": { + "@codemirror/autocomplete": "^6.20.0", + "@codemirror/commands": "^6.10.0", + "@codemirror/lang-yaml": "^6.1.0", + "@codemirror/language": "^6.13.1", + "@codemirror/search": "^6.7.2", + "@codemirror/state": "^6.5.0", + "@codemirror/view": "^6.38.0", + "@dagrejs/dagre": "^1.1.5", + "@lezer/highlight": "^1.2.5", + "@xyflow/react": "^12.8.0", + "yaml": "^2.8.0" + }, + "devDependencies": { + "@axe-core/playwright": "^4.14.0", + "@playwright/test": "^1.56.0", + "@types/dagre": "^0.7.53", + "@types/node": "^24.19.2", + "@types/react": "^19.1.0", + "@types/react-dom": "^19.1.0", + "@vitejs/plugin-react": "^5.0.0", + "react": "^19.1.0", + "react-dom": "^19.1.0", + "typescript": "^5.9.0", + "vite": "^7.1.0", + "vitest": "^5.0.5" + } +} diff --git a/ui/playground.ts b/ui/playground.ts new file mode 100644 index 0000000..a16cee0 --- /dev/null +++ b/ui/playground.ts @@ -0,0 +1,25 @@ +import { mountWorkflowEditor } from './src'; +import type { WorkflowHandle, LayoutState } from './src'; +import classify from '../workflows/classify.yml?raw'; +import parallel from '../workflows/parallel-review.yml?raw'; +import develop from '../workflows/develop.yml?raw'; +import iteration from '../workflows/develop-iteration.yml?raw'; +const examples: Record = { classify, 'parallel-review': parallel, develop, 'develop-iteration': iteration }; +const selector = document.querySelector('#example')!; +for (const key of Object.keys(examples)) selector.add(new Option(key, key)); +let instance: WorkflowHandle; +function load() { + instance?.destroy(); + let layout: LayoutState | undefined; + try { layout = JSON.parse(localStorage.getItem(`workflow-layout:${selector.value}`) ?? 'null') ?? undefined; } catch { /* Ignore stale playground state. */ } + instance = mountWorkflowEditor(document.querySelector('#app')!, { + source: examples[selector.value], layout, + agents: ['claude', 'codex', 'planner', 'builder', 'reviewer_claude', 'reviewer_codex'].map(id => ({ id })), + workflows: Object.keys(examples).map(id => ({ id: `workflows/${id}.yml` })), + onLayoutChange: next => localStorage.setItem(`workflow-layout:${selector.value}`, JSON.stringify(next)), + onSave: snapshot => { const link = document.createElement('a'); const url = URL.createObjectURL(new Blob([snapshot.source])); link.href = url; link.download = `${selector.value}.yml`; link.click(); setTimeout(() => URL.revokeObjectURL(url), 1000); }, + }); +} +selector.addEventListener('change', load); +document.querySelector('#theme')!.addEventListener('change', e => instance.update({ theme: (e.target as HTMLSelectElement).value as 'system' | 'light' | 'dark' })); +load(); diff --git a/ui/playwright.config.ts b/ui/playwright.config.ts new file mode 100644 index 0000000..0039bd9 --- /dev/null +++ b/ui/playwright.config.ts @@ -0,0 +1,9 @@ +import { defineConfig, devices } from '@playwright/test'; +export default defineConfig({ + forbidOnly: true, workers: 2, retries: 0, + reporter: [['list'], ['html', { open: 'never' }], ['./scripts/flow-reporter.mjs']], + testDir: './test/browser', fullyParallel: true, timeout: 30000, + use: { baseURL: 'http://127.0.0.1:4173', viewport: { width: 1450, height: 1000 }, trace: 'retain-on-failure' }, + projects: [{ name: 'chromium', use: devices['Desktop Chrome'] }, { name: 'firefox', use: devices['Desktop Firefox'] }, { name: 'webkit', use: devices['Desktop Safari'] }], + webServer: { command: 'npm run dev -- --port 4173 --strictPort', url: 'http://127.0.0.1:4173', reuseExistingServer: !process.env.CI }, +}); diff --git a/ui/scripts/consumers.mjs b/ui/scripts/consumers.mjs new file mode 100644 index 0000000..8132eb3 --- /dev/null +++ b/ui/scripts/consumers.mjs @@ -0,0 +1,27 @@ +import { execFileSync } from 'node:child_process'; +import { mkdirSync, copyFileSync, readFileSync, writeFileSync } from 'node:fs'; +import { createHash } from 'node:crypto'; +import { resolve } from 'node:path'; +const root = resolve(import.meta.dirname, '..'); +const artifacts = resolve(root, '.consumer-artifacts'); mkdirSync(artifacts, { recursive: true }); +const run = (cmd, args, cwd = root) => execFileSync(cmd, args, { cwd, stdio: 'inherit' }); +run('npm', ['pack', '--pack-destination', artifacts]); +const fixture = resolve(root, 'consumers/react'); +const lockPath = resolve(fixture, 'package-lock.json'); +const originalLock = readFileSync(lockPath, 'utf8'); +const lock = JSON.parse(originalLock); +// The package is rebuilt for each test. Only its tarball checksum changes; +// registry dependencies remain pinned by the checked-in consumer lockfile. +lock.packages['node_modules/@agent-workflow/ui'].integrity = 'sha512-' + createHash('sha512').update(readFileSync(resolve(artifacts, 'agent-workflow-ui-0.1.0.tgz'))).digest('base64'); +writeFileSync(lockPath, JSON.stringify(lock, null, 2) + '\n'); +try { run('npm', ['ci', '--ignore-scripts', '--no-audit', '--no-fund'], fixture); } +finally { writeFileSync(lockPath, originalLock); } +run(process.execPath, [resolve(root, 'node_modules/vite/bin/vite.js'), 'build', '--base=./', '--outDir', '../../public/packed/react', '--emptyOutDir'], fixture); +const packed = resolve(fixture, 'node_modules/@agent-workflow/ui/dist'); +const standalone = resolve(root, 'public/packed/standalone'); +mkdirSync(standalone, { recursive: true }); +copyFileSync(resolve(root, 'consumers/standalone/index.html'), resolve(standalone, 'index.html')); +for (const name of ['standalone.js', 'style.css']) copyFileSync(resolve(packed, name), resolve(standalone, name)); +// Verify declarations resolve from the packed package in a real TS consumer. +run(process.execPath, [resolve(root, 'node_modules/typescript/bin/tsc'), '--noEmit', '--jsx', 'react-jsx', '--esModuleInterop', '--skipLibCheck', '--moduleResolution', 'bundler', '--module', 'esnext', '--target', 'es2022', 'main.tsx'], fixture); +console.log('Packed React 18 and standalone consumer builds passed.'); diff --git a/ui/scripts/flow-reporter.mjs b/ui/scripts/flow-reporter.mjs new file mode 100644 index 0000000..ac62881 --- /dev/null +++ b/ui/scripts/flow-reporter.mjs @@ -0,0 +1,26 @@ +import { readFileSync } from 'node:fs'; +import path from 'node:path'; +import { parseSections, report, documentPath, specPath } from './flows-check.mjs'; + +// Runtime coverage: assertions in hooks alone cannot cover an empty case. +export default class FlowReporter { + failures = []; + sections = new Map(); + onBegin(config) { + this.sections = parseSections(readFileSync(path.resolve(config.rootDir, '../../..', documentPath), 'utf8')).sections; + } + onTestEnd(test, result) { + const match = /[/\\]flows[/\\]uf-(\d{2})\.spec\.ts$/.exec(test.location.file); + if (!match) return; + const inBody = step => step.category === 'expect' && !step.error || (step.category !== 'hook' && step.steps?.some(inBody)); + if (test.expectedStatus !== 'passed' || result.status !== 'passed' || !result.steps.some(inBody)) { + const id = `UF-${match[1]}`; + this.failures.push({ id, title: this.sections.get(id)?.title ?? test.title, file: specPath(id), testLine: test.location.line, + missing: `passing assertions in case: ${test.title}`, + action: 'Every UF case must pass and execute an assertion in its test body. Remove skip/fixme/fail declarations and add assertions for the documented outcome.' }); + } + } + onEnd() { + if (this.failures.length) { report(this.failures, process.env.BASE); return { status: 'failed' }; } + } +} diff --git a/ui/scripts/flows-check.mjs b/ui/scripts/flows-check.mjs new file mode 100644 index 0000000..8f9914b --- /dev/null +++ b/ui/scripts/flows-check.mjs @@ -0,0 +1,210 @@ +import { execFileSync, spawnSync } from 'node:child_process'; +import { readFileSync, appendFileSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +let ts; +try { ts = (await import('typescript')).default; } catch { + console.error('UI flow checker needs the locked Node dependencies. Run: make ui-install'); + process.exit(1); +} + +export const documentPath = 'docs/UI_USER_FLOWS.md'; +export const specPath = id => `ui/test/browser/flows/uf-${id.slice(3)}.spec.ts`; +const fixtures = ['ui/test/browser.html', 'ui/test/browser.tsx']; +const ignored = /(^|\/)(node_modules|dist|test-results|playwright-report|\.git)(\/|$)|\.tgz$/; +const syntaxFile = /\.[cm]?[jt]sx?$/; +const command = base => `make ui-pr-check BASE=${base ?? ''}`; +const git = (root, args, input) => execFileSync('git', ['-C', root, ...args], { encoding: 'utf8', input, stdio: ['pipe', 'pipe', 'pipe'], maxBuffer: 32 * 1024 * 1024 }); + +export function syntax(source, name = 'file.ts') { + const file = ts.createSourceFile(name, source, ts.ScriptTarget.Latest, true, name.endsWith('x') ? ts.ScriptKind.TSX : ts.ScriptKind.TS); + function node(n) { + const children = []; ts.forEachChild(n, child => { children.push(node(child)); }); + return [n.kind, (ts.isIdentifier(n) || ts.isPrivateIdentifier(n) || ts.isLiteralExpression(n) || ts.isTemplateLiteralToken(n) || ts.isJsxText(n)) ? n.text : null, ts.isVariableDeclarationList(n) ? n.flags & (ts.NodeFlags.Let | ts.NodeFlags.Const) : null, n.isTypeOnly ?? null, children]; + } + return JSON.stringify(node(file)); +} +export function prose(text) { + return text.replace(//g, '').replace(/^.*\*\*UI tests\*\*.*\n?/gm, '').replace(/\[UI tests\]\([^)]*\)/g, '').replace(/\[([^\]]+)\]\([^)]*\)/g, '$1').replace(/^\s*(?:#{1,6}\s+|[-*+]\s+|\d+\.\s+)/gm, '').replace(/`/g, '').replace(/(? /^#{1,3} /.test(next[0]))?.index ?? source.length), line: source.slice(0, heading.index).split('\n').length }); + } + if (!sections.size) errors.push({ id: 'UF-??', title: 'UI user flows', file: documentPath, missing: 'UF sections', action: 'Restore the user-flow document and its stable UF headings.' }); + return { sections, errors }; +} +export function revision(root, ref) { + const files = new Set((ref ? git(root, ['ls-tree', '-r', '--name-only', '-z', ref]) : git(root, ['ls-files', '--cached', '--others', '--exclude-standard', '-z'])).split('\0').filter(f => f && !ignored.test(f))); + const cache = new Map(); + return { files, read(file) { + if (!cache.has(file)) { + let value = null; + if (files.has(file)) { try { value = ref ? git(root, ['show', `${ref}:${file}`]) : readFileSync(path.join(root, file), 'utf8'); } catch { /* deleted working file */ } } + cache.set(file, value); + } + return cache.get(file); + } }; +} +function dependencies(view, file, found = new Set()) { + if (found.has(file)) return found; + found.add(file); const source = view.read(file); if (source === null || !syntaxFile.test(file)) return found; + const ast = ts.createSourceFile(file, source, ts.ScriptTarget.Latest, true); + function visit(node) { + let value; + if ((ts.isImportDeclaration(node) || ts.isExportDeclaration(node)) && node.moduleSpecifier && ts.isStringLiteral(node.moduleSpecifier)) value = node.moduleSpecifier.text; + if (ts.isCallExpression(node) && (node.expression.kind === ts.SyntaxKind.ImportKeyword || node.expression.getText(ast) === 'require') && node.arguments[0] && ts.isStringLiteral(node.arguments[0])) value = node.arguments[0].text; + if (value?.startsWith('.')) { + const stem = path.posix.normalize(path.posix.join(path.posix.dirname(file), value.split(/[?#]/, 1)[0])); + const candidates = [stem, ...['.ts', '.tsx', '.mts', '.cts', '.js', '.jsx', '.mjs', '.cjs', '/index.ts', '/index.tsx'].map(ext => stem + ext), stem.replace(/\.js$/, '.ts')]; + const resolved = candidates.find(candidate => view.read(candidate) !== null); + if (resolved) dependencies(view, resolved, found); + else for (const candidate of candidates) found.add(candidate); // keep deleted inputs visible + } + ts.forEachChild(node, visit); + } + visit(ast); + // The browser harness is loaded by HTML rather than a spec import. + if (file.startsWith('ui/test/browser/')) for (const fixture of fixtures) dependencies(view, fixture, found); + return found; +} +const normalized = (file, value) => value === null ? null : syntaxFile.test(file) ? syntax(value, file) : value.replace(/\s+/g, ' ').trim(); +function forbiddenDeclarations(source, file) { + const ast = ts.createSourceFile(file, source, ts.ScriptTarget.Latest, true); + let forbidden = false; + function visit(node) { + if (ts.isCallExpression(node)) { + let expression = node.expression; + let restricted = false; + while (ts.isPropertyAccessExpression(expression) || ts.isElementAccessExpression(expression)) { + const name = ts.isPropertyAccessExpression(expression) ? expression.name.text : expression.argumentExpression?.text; + restricted ||= ['skip', 'fixme', 'only', 'fail'].includes(name); + expression = expression.expression; + } + if (restricted && ts.isIdentifier(expression) && ['test', 'describe'].includes(expression.text)) forbidden = true; + } + ts.forEachChild(node, visit); + } + visit(ast); return forbidden; +} +export function analyze(current, previous) { + const parsed = parseSections(current.read(documentPath) ?? ''); const findings = [...parsed.errors]; + const old = previous ? parseSections(previous.read(documentPath) ?? '').sections : new Map(); + const affected = new Set(); const shared = new Map(); + const changedCache = new Map(); + const changed = file => { + if (!changedCache.has(file)) changedCache.set(file, !!previous && normalized(file, current.read(file)) !== normalized(file, previous.read(file))); + return changedCache.get(file); + }; + for (const [id, section] of parsed.sections) { + const file = specPath(id); const expected = `../${file}`; + const links = [...section.text.matchAll(/\[UI tests\]\(([^)]+)\)/g)]; + if (links.length !== 1 || links[0][1] !== expected) findings.push({ ...section, file: documentPath, missing: 'correct UI tests link', action: `Add [UI tests](${expected}) inside this UF section.` }); + if (current.read(file) === null) findings.push({ ...section, file, missing: 'owning Playwright spec', action: `Create ${file} with active tests exercising this flow through visible controls.` }); + } + for (const file of current.files) if (/^ui\/test\/browser\/flows\/.*\.(?:spec|test)\.[cm]?[jt]sx?$/.test(file) && current.read(file) !== null) { + const match = /\/uf-(\d{2})\.spec\.ts$/.exec(file); const id = match ? `UF-${match[1]}` : 'UF-??'; + if (!parsed.sections.has(id) || file !== specPath(id)) findings.push({ id, title: 'Orphan flow spec', file, missing: 'owning UF section', action: `Add the corresponding ${id} section and coverage link, or remove the orphan spec.` }); + const source = current.read(file); + if (forbiddenDeclarations(source, file)) findings.push({ id, title: parsed.sections.get(id)?.title ?? 'Flow tests', file, missing: 'unconditional active tests', action: 'Remove skip, fixme, only and expected-failure declarations; every UF case must pass.' }); + } + if (previous) for (const id of new Set([...old.keys(), ...parsed.sections.keys()])) { + const section = parsed.sections.get(id) ?? old.get(id); const file = specPath(id); + const docChanged = prose(parsed.sections.get(id)?.text ?? '') !== prose(old.get(id)?.text ?? ''); + const testChanged = changed(file); + const deps = new Set([...dependencies(current, file), ...dependencies(previous, file)]); + const helpers = [...deps].filter(dep => dep !== file && !dep.startsWith('ui/src/') && !/^ui\/[^/]+$/.test(dep) && changed(dep)).sort(); + const added = !old.has(id); const removed = !parsed.sections.has(id); + if (added) for (const [priorID, prior] of old) { + if (!parsed.sections.has(priorID) && prior.title === section.title) findings.push({ ...section, file: documentPath, missing: `stable identifier ${priorID}`, action: `Keep ${priorID} for this existing flow instead of renumbering it to ${id}.` }); + } + if (docChanged || testChanged || helpers.length) affected.add(id); + if (removed && current.read(file) !== null) findings.push({ ...section, file: documentPath, missing: 'removal of owning spec', action: `Remove ${file} together with the removed UF section. Do not renumber remaining IDs.` }); + else if (added && current.read(file) === null) { /* structural finding above */ } + else if (!removed && (testChanged || helpers.length) && !docChanged) { + const finding = { ...section, file: helpers.length ? helpers.join(', ') : file, missing: `substantive update to the ${id} section`, action: 'Update the flow description to match the test. If behavior is unchanged, update its Coverage note to explain the revised assertions or recovery scenario.' }; + if (helpers.length) { const key = helpers.join(', '); if (!shared.has(key)) shared.set(key, []); shared.get(key).push(finding); } + else findings.push(finding); + } else if (!removed && docChanged && !testChanged && !helpers.length) findings.push({ ...section, file: `${documentPath} (${id} section)`, missing: `substantive change to ${file}`, action: 'Update the owning spec to assert the revised behavior. Formatting and test comments do not satisfy pairing.' }); + if (!added && !removed && current.read(file) === null && previous.read(file) !== null) findings.push({ ...section, file, missing: 'removal of the UF section', action: 'Restore the active spec or remove both the spec and its UF section.' }); + } + for (const [files, group] of shared) { + findings.push(...group.map(f => ({ ...f, group: `Shared test dependency changed: ${files}; affects ${group.map(s => s.id).join(', ')}` }))); + } + const allFiles = new Set([...current.files, ...(previous?.files ?? [])]); + const full = !previous || [...allFiles].some(file => !ignored.test(file) && /^(ui\/(src\/|playground\.|index\.html|package(?:-lock)?\.json|[^/]*config\.|scripts\/(flows-check|flow-reporter|flows-check\.test))|Makefile$|workflows\/|testdata\/ui-compat\/|\.github\/workflows\/test\.yml)/.test(file) && changed(file)); + return { findings, sections: parsed.sections, selected: [...parsed.sections.keys()].filter(id => full || affected.has(id)).sort(), full }; +} +export function baseline(root, { base, ci = false, env = process.env } = {}) { + if (ci) { + const event = env.GITHUB_EVENT_PATH ? JSON.parse(readFileSync(env.GITHUB_EVENT_PATH, 'utf8')) : {}; + if (!event.pull_request) return null; + base = event.pull_request.base.sha; + if (!base) throw new Error('PR event is missing pull_request.base.sha. Supply BASE=.'); + } + base ||= 'origin/main'; + try { return git(root, ['merge-base', base, 'HEAD']).trim(); } + catch { + const shallow = git(root, ['rev-parse', '--is-shallow-repository']).trim() === 'true'; + throw new Error(`Cannot resolve merge base for ${base}. ${shallow ? 'Run: git fetch --unshallow origin\n' : ''}Run: git fetch origin ${base.startsWith('origin/') ? `${base.slice(7)}:refs/remotes/${base}` : base}\nThen rerun: ${command(base)}\nOr supply: make ui-pr-check BASE=`); + } +} +export function discovery(root) { + const run = spawnSync(process.execPath, ['node_modules/@playwright/test/cli.js', 'test', 'flows', '--list', '--forbid-only', '--reporter=json'], { cwd: path.join(root, 'ui'), encoding: 'utf8', maxBuffer: 16 * 1024 * 1024 }); + let data; try { data = JSON.parse(run.stdout); } catch { throw new Error(`Playwright discovery failed. Run: make ui-install\n${run.stderr ?? run.error?.message ?? ''}`); } + const specs = []; const walk = suite => { specs.push(...(suite.specs ?? [])); (suite.suites ?? []).forEach(walk); }; (data.suites ?? []).forEach(walk); + return { specs, projects: data.config?.projects?.map(project => project.name) ?? [], errors: [...(data.errors ?? []).map(e => e.message), ...(run.status && !data.errors?.length ? [run.stderr || 'Playwright discovery failed'] : [])] }; +} +export function checkDiscovered(sections, discovered) { + const findings = discovered.errors.map(message => ({ id: 'UF-??', title: 'Playwright discovery', file: 'ui/test/browser/flows', missing: 'valid active test discovery', action: message })); + for (const [id, section] of sections) { + const cases = discovered.specs.filter(spec => spec.file.replaceAll('\\', '/').endsWith(`flows/uf-${id.slice(3)}.spec.ts`)); + if (!cases.length || discovered.projects?.some(project => !cases.some(spec => spec.tests.some(t => t.projectName === project))) || cases.some(spec => !spec.tests.length || spec.tests.some(t => t.expectedStatus !== 'passed' || t.annotations?.some(a => ['skip', 'fixme', 'fail'].includes(a.type))))) findings.push({ ...section, file: specPath(id), missing: 'discovered active passing cases', action: 'Add active Playwright tests. Remove skip, fixme, focused and expected-failure cases; run discovery again.' }); + } + return findings; +} +export function report(findings, base, env = process.env) { + const render = f => `${f.id} — ${f.title}\nChanged: ${f.file}\nMissing: ${f.missing}\n\n${f.action}\n\nRerun: ${command(base)}`; + const groups = new Map(); + for (const finding of findings) { + const key = finding.group ?? ''; + if (!groups.has(key)) groups.set(key, []); + groups.get(key).push(finding); + } + const blocks = [...groups].flatMap(([group, items]) => [ + ...(group ? [group] : []), ...items.map(render), + ]); + if (blocks.length) process.stderr.write(blocks.join('\n\n') + '\n'); + if (env.GITHUB_ACTIONS) for (const f of findings) { + const escape = text => String(text).replaceAll('%', '%25').replaceAll('\r', '%0D').replaceAll('\n', '%0A').replaceAll(',', '%2C').replaceAll(':', '%3A'); + console.log(`::error file=${escape(f.file.split(',')[0].replace(/ \(UF-\d{2} section\)$/, ''))},line=${f.file.startsWith(documentPath) ? f.line ?? 1 : f.testLine ?? 1},title=${escape(f.id + ' — ' + f.title)}::${escape(`${f.group ? f.group + '. ' : ''}Missing: ${f.missing}. ${f.action} Rerun: ${command(base)}`)}`); + } + if (env.GITHUB_STEP_SUMMARY) appendFileSync(env.GITHUB_STEP_SUMMARY, findings.length ? blocks.map(b => `\n\n\`\`\`text\n${b}\n\`\`\``).join('') : '\nUI flow structure and synchronization passed.\n'); +} +export function main(args = process.argv.slice(2)) { + const root = git(process.cwd(), ['rev-parse', '--show-toplevel']).trim(); + const base = args.find(arg => arg.startsWith('--base='))?.slice(7) || process.env.BASE; + const ref = baseline(root, { base, ci: args.includes('--ci') }); + const displayBase = base || (args.includes('--ci') ? ref : 'origin/main'); + const result = analyze(revision(root), ref ? revision(root, ref) : null); + result.findings.push(...checkDiscovered(result.sections, discovery(root))); + report(result.findings, displayBase); + if (result.findings.length) return 1; + console.log(`UI flows: ${result.sections.size} mappings valid; ${result.full ? 'all flows selected' : `${result.selected.length} affected flows`}.`); + if (!args.includes('--run')) return 0; + if (!result.selected.length) { console.log('No UF browser run needed for these changes.'); return 0; } + console.log('Run setup if needed: make ui-install && cd ui && npx playwright install --with-deps chromium'); + const run = spawnSync(process.execPath, ['node_modules/@playwright/test/cli.js', 'test', ...result.selected.map(id => `flows/uf-${id.slice(3)}.spec.ts`), '--project=chromium', '--workers=2', '--retries=0'], { cwd: path.join(root, 'ui'), stdio: 'inherit', env: { ...process.env, BASE: displayBase ?? 'origin/main' } }); + return run.status ?? 1; +} +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + try { process.exitCode = main(); } catch (error) { const f = { id: 'UF-??', title: 'UI flow gate setup', file: documentPath, missing: 'available baseline or tooling', action: error.message }; report([f], process.env.BASE); process.exitCode = 1; } +} diff --git a/ui/scripts/flows-check.test.mjs b/ui/scripts/flows-check.test.mjs new file mode 100644 index 0000000..0ce1bac --- /dev/null +++ b/ui/scripts/flows-check.test.mjs @@ -0,0 +1,204 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtempSync, mkdirSync, writeFileSync, rmSync, symlinkSync, readFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { execFileSync, spawnSync } from 'node:child_process'; +import { analyze, revision, baseline, discovery, checkDiscovered, syntax, prose, report, specPath } from './flows-check.mjs'; + +const moduleDir = import.meta.dirname; +const git = (root, ...args) => execFileSync('git', ['-C', root, ...args], { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }).trim(); +const doc = (id = '01', text = 'Inspect a workflow.') => `### UF-${id} — Flow ${id}\n\n**UI tests**: [UI tests](../ui/test/browser/flows/uf-${id}.spec.ts)\n\n${text}\n`; +const spec = "import { test, expect } from '@playwright/test';\ntest('flow', async () => { expect(1).toBe(1); });\n"; +function fixture(t) { + const root = mkdtempSync(path.join(tmpdir(), 'ui-flows-')); t.after(() => rmSync(root, { recursive: true, force: true })); + function put(file, source) { mkdirSync(path.dirname(path.join(root, file)), { recursive: true }); writeFileSync(path.join(root, file), source); } + git(root, 'init', '-b', 'main'); git(root, 'config', 'user.name', 'Test'); git(root, 'config', 'user.email', 'test@example.invalid'); + put('.gitignore', 'ui/node_modules\nui/test-results\n'); + put('docs/UI_USER_FLOWS.md', doc() + '\n' + doc('02')); + put(specPath('UF-01'), spec); put(specPath('UF-02'), spec); + put('ui/test/browser.html', ''); put('ui/test/browser.tsx', 'export const fixture = 1;'); + put('ui/package.json', '{"type":"module"}'); + put('ui/playwright.config.ts', "export default { testDir: './test/browser', projects: [{name: 'chromium'}], forbidOnly: true, reporter: [['list'], [" + JSON.stringify(path.join(moduleDir, 'flow-reporter.mjs')) + "]] };"); + symlinkSync(path.resolve(moduleDir, '../node_modules'), path.join(root, 'ui/node_modules'), 'dir'); + git(root, 'add', '.'); git(root, 'commit', '-qm', 'baseline'); const base = git(root, 'rev-parse', 'HEAD'); + return { root, put, base, check: () => analyze(revision(root), revision(root, baseline(root, { base }))) }; +} +const missing = result => result.findings.map(f => `${f.id}: ${f.missing}`).join('\n'); + +test('valid mapping and unrelated edits select no browsers', t => { + const f = fixture(t); f.put('notes.txt', 'unrelated'); const result = f.check(); + assert.deepEqual(result.findings, []); assert.deepEqual(result.selected, []); + assert.deepEqual(checkDiscovered(result.sections, discovery(f.root)), []); +}); +test('missing, duplicate, malformed, orphan and broken links are actionable', t => { + const f = fixture(t); f.put('docs/UI_USER_FLOWS.md', doc() + doc() + '\n### UF-2 — Bad\n'); + rmSync(path.join(f.root, specPath('UF-01'))); + const result = f.check(); assert.match(missing(result), /unique UF ID/); assert.match(missing(result), /valid UF heading/); assert.match(missing(result), /owning Playwright spec/); assert.match(missing(result), /owning UF section/); + f.put('docs/UI_USER_FLOWS.md', doc().replace('uf-01.spec.ts', 'uf-02.spec.ts')); + assert.match(missing(f.check()), /correct UI tests link/); +}); +for (const edit of ['unstaged', 'staged', 'committed']) test(`${edit} UF-only and spec-only edits fail; paired edits pass`, t => { + const f = fixture(t); const finalize = () => { if (edit !== 'unstaged') git(f.root, 'add', '.'); if (edit === 'committed') git(f.root, 'commit', '-qm', 'edit'); }; + f.put('docs/UI_USER_FLOWS.md', doc('01', 'Inspect the selected job.') + doc('02')); finalize(); + assert.match(missing(f.check()), /UF-01: substantive change/); + f.put(specPath('UF-01'), spec.replace('toBe(1)', 'toEqual(1)')); finalize(); assert.deepEqual(f.check().findings, []); assert.deepEqual(f.check().selected, ['UF-01']); + f.put(specPath('UF-02'), spec.replace('expect(1)', 'expect(2)')); finalize(); assert.match(missing(f.check()), /UF-02: substantive update/); +}); +test('changes to another section cannot acknowledge the owning test', t => { + const f = fixture(t); f.put(specPath('UF-01'), spec.replace('toBe(1)', 'toEqual(1)')); + f.put('docs/UI_USER_FLOWS.md', doc() + doc('02', 'Coverage: checks restored cursor position.')); + assert.match(missing(f.check()), /UF-01: substantive update/); assert.match(missing(f.check()), /UF-02: substantive change/); +}); +test('formatting, comments, Markdown formatting and link-only edits are not pairing', t => { + assert.equal(syntax('const n = "a";'), syntax("// note\nconst n='a'")); + assert.equal(syntax('const x = { a: 1, };'), syntax('const x={a:1}')); + assert.equal(prose(doc()), prose(doc().replace('Inspect', '**Inspect**').replace('uf-01.spec.ts', 'other.ts'))); + assert.equal(prose(doc()), prose(doc() + '')); + assert.notEqual(prose('workflow_call'), prose('workflowcall')); + const f = fixture(t); f.put(specPath('UF-01'), spec + '// more explanation\n'); + f.put('docs/UI_USER_FLOWS.md', doc('01', 'New behavior') + doc('02')); + assert.match(missing(f.check()), /substantive change/); + f.put(specPath('UF-01'), spec.replace('toBe(1)', 'toEqual(1)')); f.put('docs/UI_USER_FLOWS.md', doc().replace('Inspect', '**Inspect**') + doc('02')); + assert.match(missing(f.check()), /substantive update/); +}); +test('shared imported helpers affect each dependent UF and group repairs', t => { + const f = fixture(t); + f.put('ui/test/browser/shared.ts', 'export const value = 1;'); + for (const id of ['UF-01', 'UF-02']) f.put(specPath(id), "import { value } from '../shared';\n" + spec); + git(f.root, 'add', '.'); git(f.root, 'commit', '-qm', 'helper'); f.base = git(f.root, 'rev-parse', 'HEAD'); + f.put('ui/test/browser/shared.ts', 'export const value = 2;'); + const result = analyze(revision(f.root), revision(f.root, f.base)); + assert.deepEqual(result.selected, ['UF-01', 'UF-02']); assert.equal(result.findings.length, 2); assert.match(result.findings[0].group, /UF-01, UF-02/); + f.put('docs/UI_USER_FLOWS.md', doc('01', 'Coverage: checks updated helper input.') + doc('02', 'Coverage: checks updated helper input.')); + assert.deepEqual(analyze(revision(f.root), revision(f.root, f.base)).findings, []); +}); +test('removed imports and deleted dependencies are inspected in both revisions', t => { + const f = fixture(t); f.put('ui/test/browser/shared.ts', 'export const value = 1;'); f.put(specPath('UF-01'), "import { value } from '../shared';\n" + spec); + git(f.root, 'add', '.'); git(f.root, 'commit', '-qm', 'helper'); const base = git(f.root, 'rev-parse', 'HEAD'); + rmSync(path.join(f.root, 'ui/test/browser/shared.ts')); f.put(specPath('UF-01'), spec); + const result = analyze(revision(f.root), revision(f.root, base)); assert.match(missing(result), /UF-01: substantive update/); assert.match(result.findings[0].file, /shared.ts/); +}); +test('shared HTML and TSX fixtures affect every harness flow', t => { + const f = fixture(t); f.put('ui/test/browser.html', '
Changed fixture
'); + assert.deepEqual(f.check().selected, ['UF-01', 'UF-02']); assert.equal(f.check().findings.length, 2); +}); +test('paired untracked additions and paired removals pass', t => { + const f = fixture(t); f.put('docs/UI_USER_FLOWS.md', doc() + doc('02') + doc('03')); f.put(specPath('UF-03'), spec); + assert.deepEqual(f.check().findings, []); assert.deepEqual(f.check().selected, ['UF-03']); + f.put('docs/UI_USER_FLOWS.md', doc('02') + doc('03')); rmSync(path.join(f.root, specPath('UF-01'))); + assert.deepEqual(f.check().findings, []); assert.deepEqual(f.check().selected, ['UF-03']); +}); +test('one-sided removals fail', t => { + const f = fixture(t); f.put('docs/UI_USER_FLOWS.md', doc('02')); + assert.match(missing(f.check()), /removal of owning spec/); + f.put('docs/UI_USER_FLOWS.md', doc() + doc('02')); rmSync(path.join(f.root, specPath('UF-01'))); + assert.match(missing(f.check()), /removal of the UF section/); +}); +test('implementation, styles, examples, dependencies and browser configuration select all', t => { + const f = fixture(t); + for (const file of ['ui/src/editor.tsx', 'ui/src/style.css', 'ui/playground.ts', 'workflows/example.yml', 'ui/package-lock.json', 'ui/vite.config.ts']) { + f.put(file, 'changed'); assert.deepEqual(f.check().selected, ['UF-01', 'UF-02']); rmSync(path.join(f.root, file)); + } +}); +test('generated artifacts do not affect selection or synchronization', t => { + const f = fixture(t); f.put('ui/dist/output.js', 'generated'); f.put('ui/test-results/output.txt', 'report'); f.put('ui/playwright-report/index.html', 'report'); + assert.deepEqual(f.check().selected, []); assert.deepEqual(f.check().findings, []); +}); +test('diverged branch comparison uses the merge base', t => { + const f = fixture(t); git(f.root, 'checkout', '-qb', 'feature'); f.put('notes.txt', 'feature'); git(f.root, 'add', '.'); git(f.root, 'commit', '-qm', 'feature'); + git(f.root, 'checkout', 'main'); f.put('upstream.txt', 'upstream'); git(f.root, 'add', '.'); git(f.root, 'commit', '-qm', 'upstream'); git(f.root, 'checkout', 'feature'); + assert.equal(baseline(f.root, { base: 'main' }), f.base); +}); +test('missing baseline explains fetch, explicit override and rerun commands', t => { + const f = fixture(t); assert.throws(() => baseline(f.root, { base: 'origin/unavailable' }), /git fetch origin unavailable:refs\/remotes\/origin\/unavailable[\s\S]*make ui-pr-check BASE=origin\/unavailable/); +}); +test('CI uses actual PR base SHA and push runs have no pairing baseline', t => { + const f = fixture(t); f.put('event.json', JSON.stringify({ pull_request: { base: { sha: f.base } } })); + const env = { GITHUB_EVENT_PATH: path.join(f.root, 'event.json') }; + assert.equal(baseline(f.root, { ci: true, base: 'missing', env }), f.base); + f.put('event.json', '{}'); assert.equal(baseline(f.root, { ci: true, env }), null); +}); +for (const kind of ['skip', 'fixme', 'fail', 'only']) test(`discovery rejects ${kind} cases`, t => { + const f = fixture(t); f.put(specPath('UF-01'), spec.replace("test('flow'", `test.${kind}('flow'`)); + assert.match(missing(f.check()), /unconditional active tests/); + assert.ok(checkDiscovered(f.check().sections, discovery(f.root)).length); +}); +test('discovery rejects a spec with no discovered cases', t => { + const f = fixture(t); f.put(specPath('UF-01'), 'export const empty = true;'); + assert.match(checkDiscovered(f.check().sections, discovery(f.root)).map(f => f.missing).join(), /discovered active/); +}); +for (const [name, body, status] of [ + ['empty case', '', 1], ['runtime skip', 'test.skip(true, "skip");', 1], + ['expected failure', 'test.fail(); expect(1).toBe(2);', 1], ['passing assertion', 'expect(1).toBe(1);', 0], +]) test(`runtime reporter enforces ${name}`, t => { + const f = fixture(t); f.put(specPath('UF-01'), `import { test, expect } from '@playwright/test'; test('runtime', async () => { ${body} });`); + const run = spawnSync(process.execPath, ['node_modules/@playwright/test/cli.js', 'test', 'uf-01', '--workers=2', '--retries=0'], { cwd: path.join(f.root, 'ui'), encoding: 'utf8' }); + assert.equal(run.status, status, run.stdout + run.stderr); + if (status) assert.match(run.stderr, /must pass and execute an assertion/); +}); +test('failure output, annotations and summary carry the same repair instructions', t => { + const f = fixture(t); f.put(specPath('UF-01'), spec.replace('toBe(1)', 'toEqual(1)')); + const result = f.check(); const summary = path.join(f.root, 'summary'); + const errors = [], logs = []; const stderr = process.stderr.write, log = console.log; + try { process.stderr.write = text => { errors.push(text); return true; }; console.log = text => logs.push(text); report(result.findings, 'origin/main', { GITHUB_ACTIONS: 'true', GITHUB_STEP_SUMMARY: summary }); } + finally { process.stderr.write = stderr; console.log = log; } + assert.match(errors.join(''), /UF-01 — Flow 01[\s\S]*Changed:[\s\S]*Missing:[\s\S]*Coverage note[\s\S]*make ui-pr-check BASE=origin\/main/); + assert.match(logs.join(''), /::error file=.*UF-01/); + assert.ok(readFileSync(summary, 'utf8').includes(errors.join('').trim())); +}); + +test('stable identifiers cannot be renumbered by a paired remove/add', t => { + const f = fixture(t); f.put('docs/UI_USER_FLOWS.md', doc().replaceAll('UF-01', 'UF-03').replaceAll('uf-01', 'uf-03') + doc('02')); + rmSync(path.join(f.root, specPath('UF-01'))); f.put(specPath('UF-03'), spec); + assert.match(missing(f.check()), /stable identifier UF-01/); +}); +test('transitive helpers select only their importing flows', t => { + const f = fixture(t); f.put('ui/test/browser/a.ts', "export { value } from './b';"); f.put('ui/test/browser/b.ts', 'export const value = 1;'); + f.put(specPath('UF-01'), "import { value } from '../a';\n" + spec); + git(f.root, 'add', '.'); git(f.root, 'commit', '-qm', 'imports'); const base = git(f.root, 'rev-parse', 'HEAD'); + f.put('ui/test/browser/b.ts', 'export const value = 2;'); + const result = analyze(revision(f.root), revision(f.root, base)); + assert.deepEqual(result.selected, ['UF-01']); assert.match(missing(result), /UF-01: substantive update/); +}); +test('missing project coverage is rejected by discovery validation', t => { + const f = fixture(t); const result = f.check(); const found = discovery(f.root); found.projects.push('firefox'); + assert.equal(checkDiscovered(result.sections, found).length, 2); +}); +test('nested and differently named flow specs are orphaned', t => { + const f = fixture(t); f.put('ui/test/browser/flows/nested/uf-01.spec.ts', spec); f.put('ui/test/browser/flows/extra.test.ts', spec); + assert.equal(f.check().findings.filter(f => f.missing === 'owning UF section').length, 2); +}); +test('hook assertions alone cannot cover an empty case', t => { + const f = fixture(t); f.put(specPath('UF-01'), "import { test, expect } from '@playwright/test'; test.beforeEach(() => { expect(1).toBe(1); }); test('empty', async () => {});"); + const run = spawnSync(process.execPath, ['node_modules/@playwright/test/cli.js', 'test', 'uf-01'], { cwd: path.join(f.root, 'ui'), encoding: 'utf8' }); + assert.equal(run.status, 1); assert.match(run.stderr, /must pass and execute an assertion/); +}); + +test('unrelated command completes without executing browser tests', t => { + const f = fixture(t); f.put('notes.txt', 'unrelated'); + const run = spawnSync(process.execPath, [path.join(moduleDir, 'flows-check.mjs'), `--base=${f.base}`, '--run'], { cwd: f.root, encoding: 'utf8' }); + assert.equal(run.status, 0, run.stderr); assert.match(run.stdout, /No UF browser run needed/); assert.doesNotMatch(run.stdout, /Running \d+ tests/); +}); +test('test marker strings and comments are not skipped test declarations', t => { + const f = fixture(t); f.put(specPath('UF-01'), spec.replace('expect(1)', 'expect("test.skip")') + '// test.only\n'); + assert.ok(!f.check().findings.some(f => f.missing === 'unconditional active tests')); +}); +test('Markdown formatting and TypeScript comments select no flows', t => { + const f = fixture(t); f.put('docs/UI_USER_FLOWS.md', doc().replace('Inspect', '**Inspect**') + '\n' + doc('02')); + f.put(specPath('UF-01'), '// explanatory comment\n' + spec.replace('toBe(1)', 'toBe( 1 )')); + assert.deepEqual(f.check().findings, []); assert.deepEqual(f.check().selected, []); +}); +test('missing locked dependencies produces setup guidance', t => { + const f = fixture(t); rmSync(path.join(f.root, 'ui/node_modules')); + f.put('ui/scripts/flows-check.mjs', readFileSync(path.join(moduleDir, 'flows-check.mjs'), 'utf8')); + const run = spawnSync(process.execPath, [path.join(f.root, 'ui/scripts/flows-check.mjs')], { cwd: f.root, encoding: 'utf8' }); + assert.equal(run.status, 1); assert.match(run.stderr, /Run: make ui-install/); +}); + +test('nested documentation headings stay inside their owning UF section', t => { + const f = fixture(t); f.put('docs/UI_USER_FLOWS.md', doc('01', 'Inspect a workflow.\n#### Coverage\nOld scenario.') + doc('02')); + git(f.root, 'add', '.'); git(f.root, 'commit', '-qm', 'nested coverage'); const base = git(f.root, 'rev-parse', 'HEAD'); + f.put('docs/UI_USER_FLOWS.md', doc('01', 'Inspect a workflow.\n#### Coverage\nNew recovery scenario.') + doc('02')); + assert.match(missing(analyze(revision(f.root), revision(f.root, base))), /UF-01: substantive change/); +}); diff --git a/ui/src/document.ts b/ui/src/document.ts new file mode 100644 index 0000000..62ac565 --- /dev/null +++ b/ui/src/document.ts @@ -0,0 +1,298 @@ +import { isMap, isScalar, isSeq, type Document, type Node } from 'yaml'; +import type { Diagnostic, LayoutState, Path, Snapshot, Step, WorkflowOptions } from './types'; +import { dependencies, effectiveStepID, identifier, parseWorkflow } from './validation'; +import { hasExpressionReferences, isExpressionPath, mapReferences, pathJob, references, referenceText, type Reference } from './expressions'; + +const emptyLayout = (): LayoutState => ({ positions: {}, collapsed: {} }); +export class WorkflowDocument { + private options: WorkflowOptions; + private parsed: ReturnType; + private snapshot: Snapshot; + private listeners = new Set<() => void>(); + private past: string[] = []; + private future: string[] = []; + private validation?: AbortController; + private epoch = 0; + private disposed = false; + private historyGroup?: string; + private lastEditTime = 0; + readonly readOnly: boolean; + layout: LayoutState; + constructor(options: WorkflowOptions, readOnly = false) { + this.options = options; this.readOnly = readOnly; + this.layout = structuredClone(options.layout ?? emptyLayout()); + this.parsed = parseWorkflow(options.source); + this.snapshot = { source: options.source, workflow: this.parsed.workflow, previewWorkflow: this.parsed.workflow, diagnostics: this.parsed.diagnostics, stale: !this.parsed.usable, revision: 0, canUndo: false, canRedo: false, saving: false, validating: false }; + } + start = () => { this.disposed = false; void this.validate(); }; + getOptions = () => this.options; + getSnapshot = () => this.snapshot; + subscribe = (listener: () => void) => { this.listeners.add(listener); return () => { this.listeners.delete(listener); }; }; + private emit() { if (!this.disposed) { this.listeners.forEach(fn => fn()); this.options.onChange?.(structuredClone(this.snapshot)); } } + update(options: Partial) { + const previous = this.options; + this.options = { ...previous, ...options }; + if (options.layout) this.layout = structuredClone(options.layout); + if (options.source !== undefined && options.source !== this.snapshot.source) this.commit(options.source, true); + else { this.snapshot = { ...this.snapshot }; this.listeners.forEach(fn => fn()); if (this.options.validate !== previous.validate || this.options.validationKey !== previous.validationKey || JSON.stringify(this.options.agents) !== JSON.stringify(previous.agents) || JSON.stringify(this.options.workflows) !== JSON.stringify(previous.workflows)) void this.validate(); } + } + setLayout(next: LayoutState) { this.layout = structuredClone(next); this.options.onLayoutChange?.(structuredClone(this.layout)); this.snapshot = { ...this.snapshot }; this.listeners.forEach(fn => fn()); } + private commit(source: string, history: boolean, group?: string) { + if (this.disposed || source === this.snapshot.source) return; + if (history) { + const now = Date.now(); + if (!group || group !== this.historyGroup || now - this.lastEditTime > 750) this.past.push(this.snapshot.source); + this.future = []; this.lastEditTime = now; + } + this.historyGroup = history ? group : undefined; + this.parsed = parseWorkflow(source); + this.snapshot = { ...this.snapshot, source, workflow: this.parsed.workflow, previewWorkflow: this.parsed.workflow ?? this.snapshot.previewWorkflow, diagnostics: this.parsed.diagnostics, stale: !this.parsed.usable, revision: this.snapshot.revision + 1, canUndo: !!this.past.length, canRedo: !!this.future.length, saveError: undefined }; + this.emit(); void this.validate(); + } + getPathAtOffset(offset: number): Path { + let result: Path = []; + const walk = (node: Node | null, path: Path) => { + if (!node?.range || offset < node.range[0] || offset > node.range[2]) return; + result = path; + if (isMap(node)) node.items.forEach(pair => { + if (isScalar(pair.key)) walk(pair.value as Node, [...path, String(pair.key.value)]); + }); + if (isSeq(node)) node.items.forEach((item, index) => walk(item as Node, [...path, index])); + }; + walk(this.parsed.doc?.contents ?? null, []); + return result; + } + getSourceRange(path: Path): { from: number; to: number } | undefined { + if (!this.parsed.usable || !this.parsed.doc) return; + const node = this.parsed.doc.getIn(path, true) as Node | undefined; + if (!node?.range) return; + const parent = this.parsed.doc.getIn(path.slice(0, -1), true); + const key = isMap(parent) ? parent.items.find(pair => isScalar(pair.key) && pair.key.value === path.at(-1))?.key : undefined; + return { from: isScalar(key) && key.range ? key.range[0] : node.range[0], to: node.range[1] }; + } + setSource(source: string, group?: string) { if (!this.readOnly) this.commit(source, true, group); } + undo() { if (!this.readOnly && this.past.length) { this.future.push(this.snapshot.source); this.commit(this.past.pop()!, false); } } + redo() { if (!this.readOnly && this.future.length) { this.past.push(this.snapshot.source); this.commit(this.future.pop()!, false); } } + private edit(fn: (doc: Document) => void) { + if (this.readOnly) throw new Error('Viewer is read-only'); + if (!this.parsed.usable || !this.parsed.doc) throw new Error('Visual editing is suspended while the preview is stale'); + const doc = this.parsed.doc.clone(); fn(doc); this.commit(doc.toString({ lineWidth: 0 }), true); + } + setField(path: Path, value: unknown) { + this.edit(doc => { + if (value === undefined) { doc.deleteIn(path); return; } + // Optional mappings such as repeat may be explicitly null in imported + // YAML. Materialize only the ancestors the user is now editing. + for (let length = 1; length < path.length; length++) { + const prefix = path.slice(0, length); + const node = doc.getIn(prefix, true); + if (node == null || (isScalar(node) && node.value == null)) { + const replacement = doc.createNode(typeof path[length] === 'number' ? [] : {}); + if (isScalar(node)) { + replacement.comment = node.comment; + replacement.commentBefore = node.commentBefore; + replacement.spaceBefore = node.spaceBefore; + } + doc.setIn(prefix, replacement); + } + } + doc.setIn(path, value); + }); + } + private unique(base: string, ids: string[]) { let id = base; let i = 2; while (ids.includes(id)) id = `${base}_${i++}`; return id; } + addJob(uses?: string): string { + const id = this.unique('job', Object.keys(this.snapshot.workflow?.jobs ?? {})); + this.edit(doc => doc.setIn(['jobs', id], uses ? { uses } : { steps: [{ id: 'step_1', argv: ['echo', 'Hello'] }] })); return id; + } + setJobKind(id: string, reusable: boolean) { + this.edit(doc => { + if (reusable) { doc.deleteIn(['jobs', id, 'steps']); doc.setIn(['jobs', id, 'uses'], this.options.workflows?.[0]?.id ?? 'workflows/example.yml'); } + else { + for (const key of ['uses', 'with', 'secrets']) doc.deleteIn(['jobs', id, key]); + doc.setIn(['jobs', id, 'steps'], [{ id: 'step_1', argv: ['echo', 'Hello'] }]); + } + }); + } + duplicateJob(id: string): string { + const next = this.unique(`${id}_copy`, Object.keys(this.snapshot.workflow?.jobs ?? {})); + this.edit(doc => { const job = doc.getIn(['jobs', id], true) as Node; if (!job) throw new Error('Unknown job'); doc.setIn(['jobs', next], job.clone()); }); return next; + } + renameJob(id: string, next: string) { + if (id === next) return; + this.edit(doc => { + if (!identifier.test(next) || doc.hasIn(['jobs', next])) throw new Error('Choose a unique valid job ID'); + const jobs = doc.get('jobs', true); + if (!isMap(jobs)) throw new Error('Missing jobs'); + const pair = jobs.items.find(p => isScalar(p.key) && p.key.value === id); + if (!pair || !isScalar(pair.key)) throw new Error('Unknown job'); pair.key.value = next; + for (const [jobID, job] of Object.entries(this.snapshot.workflow!.jobs)) { + if (dependencies(job).includes(id)) doc.setIn(['jobs', jobID === id ? next : jobID, 'needs'], dependencies(job).map(dep => dep === id ? next : dep)); + } + this.rewrite(doc, (scope, ref) => (scope === 'jobs' || scope === 'needs') && ref === id ? next : undefined); + }); + const layout = structuredClone(this.layout); + if (Object.hasOwn(layout.positions, id)) { layout.positions = { ...layout.positions, [next]: layout.positions[id] }; delete layout.positions[id]; } + if (Object.hasOwn(layout.collapsed, id)) { layout.collapsed = { ...layout.collapsed, [next]: layout.collapsed[id] }; delete layout.collapsed[id]; } + this.setLayout(layout); + } + private rewrite(doc: Document, rename: (scope: string, id: string, path: Path) => string | undefined) { + this.rewriteReferences(doc, (ref, path) => { + const next = rename(ref.scope, ref.id, path); + return next ? referenceText({ ...ref, id: next }) : undefined; + }); + } + private rewriteReferences(doc: Document, rename: (ref: Reference, path: Path) => string | undefined) { + // Walk with explicit paths so local step references only change in their own job. + const walk = (node: Node | null, path: Path) => { + if (isScalar(node) && typeof node.value === 'string' && hasExpressionReferences(path)) node.value = mapReferences(node.value, ref => rename(ref, path), isExpressionPath(path)); + if (isMap(node)) node.items.forEach(p => walk(p.value as Node, [...path, String(isScalar(p.key) ? p.key.value : '')])); + if (isSeq(node)) node.items.forEach((n, i) => walk(n as Node, [...path, i])); + }; + walk(doc.contents, []); + } + private entryReference(path: Path, ref: Reference): boolean { + const key = String(path[3]); + if (path.length === 4 && path[0] === 'on' && path[1] === 'workflow_call' && ['inputs', 'secrets', 'outputs'].includes(String(path[2]))) { + return ref.scope === path[2] && ref.id === key; + } + if (path.length === 4 && path[0] === 'jobs' && path[2] === 'outputs') { + return (ref.scope === 'jobs' || ref.scope === 'needs') && ref.id === path[1] && ref.output === key; + } + throw new Error('Expected a callable interface entry or job output path'); + } + renameEntry(path: Path, next: string) { + // Validate the supported path even when its current key matches next. + this.entryReference(path, { scope: 'inputs', id: '' , offset: 0 }); + if (path[3] === next) return; + this.edit(doc => { + const parent = doc.getIn(path.slice(0, -1), true); + if (!identifier.test(next) || !isMap(parent) || parent.has(next)) throw new Error('Choose a unique valid entry ID'); + const pair = parent.items.find(item => isScalar(item.key) && item.key.value === path[3]); + if (!pair || !isScalar(pair.key)) throw new Error('Unknown entry'); + pair.key.value = next; + this.rewriteReferences(doc, ref => this.entryReference(path, ref) + ? referenceText(path[0] === 'jobs' ? { ...ref, output: next } : { ...ref, id: next }) : undefined); + }); + } + entryDeletionImpact(path: Path): Path[] { + this.entryReference(path, { scope: 'inputs', id: '', offset: 0 }); + const result: Path[] = []; + const walk = (value: unknown, location: Path) => { + if (path.every((part, i) => location[i] === part)) return; + if (typeof value === 'string') { + if (hasExpressionReferences(location) && references(value, isExpressionPath(location)).some(ref => this.entryReference(path, ref))) result.push(location); + } else if (Array.isArray(value)) value.forEach((item, i) => walk(item, [...location, i])); + else if (value && typeof value === 'object') Object.entries(value).forEach(([key, item]) => walk(item, [...location, key])); + }; + walk(this.snapshot.workflow, []); + return result; + } + deleteEntry(path: Path, confirmed = false) { + if (this.entryDeletionImpact(path).length && !confirmed) throw new Error('Deletion has references; confirmation is required'); + this.edit(doc => { if (!doc.deleteIn(path)) throw new Error('Unknown entry'); }); + } + deletionImpact(job: string, stepIndex?: number): Path[] { + const result: Path[] = []; + const stepID = stepIndex == null ? undefined : effectiveStepID(this.snapshot.workflow!.jobs[job].steps![stepIndex], stepIndex); + const walk = (value: unknown, path: Path) => { + if (typeof value === 'string') { + if (hasExpressionReferences(path) && references(value, isExpressionPath(path)).some(ref => stepID ? ref.scope === 'steps' && ref.id === stepID && pathJob(path) === job : (ref.scope === 'jobs' || ref.scope === 'needs') && ref.id === job)) result.push(path); + } else if (Array.isArray(value)) value.forEach((v, i) => walk(v, [...path, i])); + else if (value && typeof value === 'object') Object.entries(value).forEach(([k, v]) => walk(v, [...path, k])); + }; + walk(this.snapshot.workflow, []); + if (!stepID) for (const [id, value] of Object.entries(this.snapshot.workflow!.jobs)) if (dependencies(value).includes(job)) result.push(['jobs', id, 'needs']); + return result; + } + deleteJob(id: string, confirmed = false) { + if (this.deletionImpact(id).length && !confirmed) throw new Error('Deletion has references; confirmation is required'); + this.edit(doc => { + doc.deleteIn(['jobs', id]); + for (const [key, job] of Object.entries(this.snapshot.workflow!.jobs)) if (key !== id && dependencies(job).includes(id)) doc.setIn(['jobs', key, 'needs'], dependencies(job).filter(dep => dep !== id)); + }); + } + connect(from: string, to: string) { + const jobs = this.snapshot.workflow?.jobs ?? {}; + if (!Object.hasOwn(jobs, from) || !Object.hasOwn(jobs, to)) throw new Error('Unknown job'); + if (from === to || dependencies(jobs[to]).includes(from)) throw new Error('Self or duplicate dependency'); + const reaches = (id: string, target: string, seen = new Set()): boolean => id === target || (Object.hasOwn(jobs, id) && !seen.has(id) && (seen.add(id), dependencies(jobs[id]).some(dep => reaches(dep, target, seen)))); + if (reaches(from, to)) throw new Error('Connection would create a cycle'); + this.setField(['jobs', to, 'needs'], [...dependencies(jobs[to]), from]); + } + disconnect(from: string, to: string) { this.setField(['jobs', to, 'needs'], dependencies(this.snapshot.workflow!.jobs[to]).filter(dep => dep !== from)); } + private materialize(doc: Document, job: string) { + const steps = this.snapshot.workflow!.jobs[job].steps ?? []; + steps.forEach((step, i) => { if (!step.id) doc.setIn(['jobs', job, 'steps', i, 'id'], effectiveStepID(step, i)); }); + } + addStep(job: string, template: Step = { argv: ['echo', 'Hello'] }): number { + const steps = this.snapshot.workflow!.jobs[job].steps ?? []; + this.edit(doc => { + this.materialize(doc, job); + if (!doc.hasIn(['jobs', job, 'steps'])) doc.setIn(['jobs', job, 'steps'], doc.createNode([])); + doc.addIn(['jobs', job, 'steps'], { ...template, id: this.unique('step', steps.map(effectiveStepID)) }); + }); return steps.length; + } + renameStep(job: string, index: number, next: string) { + const steps = this.snapshot.workflow!.jobs[job].steps!; + const old = effectiveStepID(steps[index], index); + if (next === old) return; + if (!identifier.test(next) || steps.some((s, i) => i !== index && effectiveStepID(s, i) === next)) throw new Error('Choose a unique valid step ID'); + this.edit(doc => { + doc.setIn(['jobs', job, 'steps', index, 'id'], next); + this.rewrite(doc, (scope, id, path) => scope === 'steps' && id === old && pathJob(path) === job ? next : undefined); + }); + } + duplicateStep(job: string, index: number) { + this.edit(doc => { + this.materialize(doc, job); + const seq = doc.getIn(['jobs', job, 'steps'], true); + if (!isSeq(seq)) throw new Error('Missing steps'); + const node = (seq.items[index] as Node).clone(); + const id = this.unique(`${effectiveStepID(this.snapshot.workflow!.jobs[job].steps![index], index)}_copy`, this.snapshot.workflow!.jobs[job].steps!.map(effectiveStepID)); + if (isMap(node)) node.set('id', id); + seq.items.splice(index + 1, 0, node); + }); + } + deleteStep(job: string, index: number, confirmed = false) { + if (this.deletionImpact(job, index).length && !confirmed) throw new Error('Deletion has references; confirmation is required'); + this.edit(doc => { this.materialize(doc, job); doc.deleteIn(['jobs', job, 'steps', index]); }); + } + moveStep(job: string, from: number, to: number) { + this.edit(doc => { + this.materialize(doc, job); const seq = doc.getIn(['jobs', job, 'steps'], true); + if (!isSeq(seq) || from < 0 || to < 0 || from >= seq.items.length || to >= seq.items.length) throw new Error('Invalid step position'); + seq.items.splice(to, 0, seq.items.splice(from, 1)[0]); + }); + } + dismissSaveError() { + if (this.snapshot.saveError) { + this.snapshot = { ...this.snapshot, saveError: undefined }; + this.emit(); + } + } + async save() { + if (this.readOnly || !this.options.onSave || this.snapshot.saving) return; + const revision = this.snapshot.revision; + this.snapshot = { ...this.snapshot, saving: true, saveError: undefined }; this.emit(); + try { await this.options.onSave(structuredClone(this.snapshot)); } + catch (error) { if (!this.disposed && revision === this.snapshot.revision) this.snapshot = { ...this.snapshot, saveError: String(error) }; } + finally { if (!this.disposed) { this.snapshot = { ...this.snapshot, saving: false }; this.emit(); } } + } + async validate() { + this.validation?.abort(); const controller = new AbortController(); this.validation = controller; const epoch = ++this.epoch; + let diagnostics: Diagnostic[] = []; + const w = this.parsed.workflow; + if (w) for (const [id, job] of Object.entries(w.jobs)) { + if (job.uses && this.options.workflows && !this.options.workflows.some(item => item.id.replace(/^\.\//, '') === job.uses!.replace(/^\.\//, ''))) diagnostics.push({ code: 'workflow-catalog', message: `Workflow ${job.uses} is unavailable in the host catalog`, path: ['jobs', id, 'uses'], source: 'host', severity: 'warning' }); + job.steps?.forEach((step, i) => { + if (step.uses?.startsWith('agent/') && this.options.agents && !this.options.agents.some(a => a.id === step.uses!.slice(6))) diagnostics.push({ code: 'agent-catalog', message: `Agent ${step.uses} is unavailable in the host catalog`, path: ['jobs', id, 'steps', i, 'uses'], source: 'host', severity: 'warning' }); + }); + } + this.snapshot = { ...this.snapshot, diagnostics: [...this.parsed.diagnostics, ...diagnostics], validating: !!this.options.validate }; this.emit(); + if (!this.options.validate) return; + try { diagnostics = [...diagnostics, ...await this.options.validate(structuredClone(this.snapshot), controller.signal)]; } + catch (error) { if (!controller.signal.aborted) diagnostics.push({ code: 'host-validation', message: String(error), path: [], severity: 'error', source: 'host' }); } + if (!this.disposed && epoch === this.epoch) { this.snapshot = { ...this.snapshot, diagnostics: [...this.parsed.diagnostics, ...diagnostics], validating: false }; this.emit(); } + } + destroy() { this.disposed = true; this.validation?.abort(); this.listeners.clear(); } +} diff --git a/ui/src/editor.tsx b/ui/src/editor.tsx new file mode 100644 index 0000000..c5f9cb4 --- /dev/null +++ b/ui/src/editor.tsx @@ -0,0 +1,282 @@ +import { useEffect, useId, useMemo, useRef, useState, useSyncExternalStore, type CSSProperties } from 'react'; +import { ReactFlow, ReactFlowProvider, Background, Controls, Handle, Position, useReactFlow, useNodesInitialized, type NodeProps, type Node, type Edge, MarkerType } from '@xyflow/react'; +import dagre from '@dagrejs/dagre'; +import { WorkflowDocument } from './document'; +import { dependencies, effectiveStepID } from './validation'; +import { Inspector, InspectorDraftContext, InspectorDrafts, type Selection } from './forms'; +import { SourceEditor } from './source'; +import type { Job, LayoutState, Path, Step, WorkflowOptions } from './types'; +import '@xyflow/react/dist/style.css'; +import './style.css'; + +type CardData = { job: Job; id: string; collapsed: boolean; readOnly: boolean; selectedStep?: number; select: (step?: number) => void; toggle: () => void }; +type JobNode = Node; +type PendingDeletion = { impact: string[]; revision: number } & ({ job: string; step?: number } | { path: Path }); +function JobCard({ data, selected }: NodeProps) { + return
+ +
+
{data.job.if && if}{data.job.repeat && repeat ≤ {data.job.repeat['max-attempts']}}{data.job.uses && reusable call}{!!data.job['continue-on-error'] && continue on error}
+ {!data.collapsed &&
    {data.job.steps?.map((step, i) =>
  1. )}{data.job.uses &&
  2. {data.job.uses}
  3. }
} + +
; +} +const nodeTypes = { job: JobCard }; +export function automaticLayout(jobs: Record, collapsed: LayoutState['collapsed'] = {}): LayoutState['positions'] { + const graph = new dagre.graphlib.Graph().setGraph({ rankdir: 'TB', nodesep: 64, ranksep: 96 }).setDefaultEdgeLabel(() => ({})); + // Graphlib uses object dictionaries internally. Map workflow IDs to private + // keys so valid IDs such as __proto__ cannot affect its layout bookkeeping. + const keys = new Map(Object.keys(jobs).map((id, index) => [id, `job-${index}`])); + for (const [id, job] of Object.entries(jobs)) graph.setNode(keys.get(id)!, { width: 280, height: (Object.hasOwn(collapsed, id) && collapsed[id]) ? 100 : 120 + 60 * (job.steps?.length ?? 1) }); + for (const [id, job] of Object.entries(jobs)) for (const dep of dependencies(job)) if (keys.has(dep)) graph.setEdge(keys.get(dep)!, keys.get(id)!); + dagre.layout(graph); + return Object.fromEntries(Object.keys(jobs).map(id => { const node = graph.node(keys.get(id)!); return [id, { x: node.x - node.width / 2, y: node.y - node.height / 2 }]; })); +} +const defaultTemplates: { label: string; step: Step }[] = [ + { label: 'Direct command', step: { argv: ['echo', 'Hello'] } }, + { label: 'Shell command', step: { shell: 'sh', run: 'echo Hello' } }, + ...['workspace/create', 'workspace/snapshot', 'workspace/integrate', 'git/commit', 'github/pr'].map(uses => ({ label: uses, step: { uses, with: {} } })), +]; +function EditorSurface({ store }: { store: WorkflowDocument }) { + const snapshot = useSyncExternalStore(store.subscribe, store.getSnapshot, store.getSnapshot); + const [selection, select] = useState({}); + const [sourceNavigation, navigateSource] = useState(0); + const [mode, setMode] = useState<'visual' | 'yaml' | 'split'>('split'); + const modeChosen = useRef(false); + const [panel, setPanel] = useState<'canvas' | 'palette' | 'inspector'>('canvas'); + const [geometry, setGeometry] = useState>>({}); + const [query, setQuery] = useState(''); const [paletteQuery, setPaletteQuery] = useState(''); + const [error, setError] = useState(''); + const [deletion, setDeletion] = useState(); + const flow = useReactFlow(); const id = useId(); + const canvas = useRef(null); + const nodesInitialized = useNodesInitialized(); + const graphWasObserved = useRef(false); + const pendingResizeFit = useRef(false); + const [fitRequest, requestFit] = useState(0); + const root = useRef(null); + const returnFocus = useRef(null); + const pendingFocus = useRef(undefined); + const [narrow, setNarrow] = useState(false); + const options = store.getOptions(); const w = snapshot.workflow ?? snapshot.previewWorkflow; + const disabled = store.readOnly || snapshot.stale; + const drafts = useMemo(() => new InspectorDrafts(), []); + const attempt = (fn: () => void) => { try { fn(); setError(''); } catch (e) { setError(String(e)); } }; + // Pointer Save keeps the active field focused until this preflight. A blur + // rename can otherwise scroll the page between pointer down and click. + const save = () => { + if (store.readOnly || !store.getOptions().onSave || store.getSnapshot().saving) return; + const rejected = drafts.apply(); + if (rejected) { setPanel('inspector'); rejected.reveal(); return; } + void store.save(); + }; + const choose = (next: Selection) => { + if (next.job && !Object.hasOwn(geometry, next.job)) pendingFocus.current = next.job; + select(next); navigateSource(value => value + 1); setPanel('inspector'); + }; + useEffect(() => { if (selection.job && (!w || !Object.hasOwn(w.jobs, selection.job))) select({}); }, [w, selection.job]); + useEffect(() => { if (narrow && !modeChosen.current) setMode('visual'); }, [narrow]); + const defaults = useMemo(() => automaticLayout(w?.jobs ?? {}, store.layout.collapsed), [w, store.layout.collapsed]); + useEffect(() => { + const missing = Object.keys(w?.jobs ?? {}).filter(key => !Object.hasOwn(store.layout.positions, key)); + if (missing.length) { + const positions = { ...store.layout.positions }; + for (const key of missing) { + const candidate = { ...defaults[key] }; + const height = 120 + 60 * (w!.jobs[key].steps?.length ?? 1); + const overlaps = () => Object.entries(positions).some(([other, position]) => { + if (!Object.hasOwn(w!.jobs, other)) return false; + const otherHeight = Object.hasOwn(geometry, other) ? geometry[other].measured?.height ?? 180 : 120 + 60 * (w!.jobs[other].steps?.length ?? 1); + return candidate.x < position.x + 312 && candidate.x + 312 > position.x && candidate.y < position.y + otherHeight + 32 && candidate.y + height + 32 > position.y; + }); + while (overlaps()) candidate.x += 344; + Object.defineProperty(positions, key, { value: candidate, enumerable: true, writable: true, configurable: true }); + } + store.setLayout({ ...store.layout, positions }); + } + }, [store, w, defaults]); + useEffect(() => { + const job = pendingFocus.current; + if (job && Object.hasOwn(geometry, job) && geometry[job].measured) { + pendingFocus.current = undefined; + void flow.fitView({ nodes: [{ id: job }], maxZoom: 1, padding: 0.2, duration: 0 }); + } + }, [geometry, flow]); + const viewport = store.layout.viewport; + useEffect(() => { + if (viewport) { + const current = flow.getViewport(); + if (current.x !== viewport.x || current.y !== viewport.y || current.zoom !== viewport.zoom) void flow.setViewport(viewport, { duration: 0 }); + } + }, [flow, viewport?.x, viewport?.y, viewport?.zoom]); + useEffect(() => { + if (!canvas.current) return; + let lastSize: { width: number; height: number } | undefined; + const observer = new ResizeObserver(([entry]) => { + const { width, height } = entry.contentRect; + if (width > 0 && height > 0 && ((lastSize && (lastSize.width !== width || lastSize.height !== height)) || (!lastSize && graphWasObserved.current))) { + pendingResizeFit.current = true; + requestFit(value => value + 1); + } + graphWasObserved.current = true; + lastSize = { width, height }; + }); + observer.observe(canvas.current); + return () => observer.disconnect(); + }, [flow, mode]); + useEffect(() => { + if (!pendingResizeFit.current || !nodesInitialized || !flow.viewportInitialized || mode === 'yaml') return; + const frame = requestAnimationFrame(() => { + void flow.fitView({ duration: 0, maxZoom: 1, padding: 0.2 }).then(fitted => { if (fitted) pendingResizeFit.current = false; }); + }); + return () => cancelAnimationFrame(frame); + }, [fitRequest, nodesInitialized, flow.viewportInitialized, flow, mode]); + const nodes: JobNode[] = Object.entries(w?.jobs ?? {}).map(([jobID, job]) => ({ + ...(Object.hasOwn(geometry, jobID) ? geometry[jobID] : {}), + id: jobID, type: 'job', position: (Object.hasOwn(store.layout.positions, jobID) ? store.layout.positions[jobID] : defaults[jobID]), selected: selection.job === jobID, + // Keep matching dependency context on the canvas; use dimming instead of hiding jobs. + className: query && !`${jobID} ${JSON.stringify(job)}`.toLowerCase().includes(query.toLowerCase()) ? 'aw-dimmed' : '', + data: { job, id: jobID, collapsed: (Object.hasOwn(store.layout.collapsed, jobID) && !!store.layout.collapsed[jobID]), readOnly: disabled, selectedStep: selection.job === jobID ? selection.step : undefined, + select: step => choose({ job: jobID, step }), toggle: () => store.setLayout({ ...store.layout, collapsed: { ...store.layout.collapsed, [jobID]: !(Object.hasOwn(store.layout.collapsed, jobID) && store.layout.collapsed[jobID]) } }) }, + })); + const edges: Edge[] = Object.entries(w?.jobs ?? {}).flatMap(([jobID, job]) => dependencies(job).filter(dep => Object.hasOwn(w!.jobs, dep)).map(dep => ({ id: `${dep}->${jobID}`, source: dep, target: jobID, markerEnd: { type: MarkerType.ArrowClosed } }))); + const templates = [{ label: 'Agent', step: { uses: `agent/${options.agents?.[0]?.id ?? 'claude'}`, with: { prompt: '' } } }, ...defaultTemplates, ...(options.agents ?? []).map(agent => ({ label: agent.label ?? agent.id, step: { uses: `agent/${agent.id}`, with: { prompt: '' } } })), ...(options.actions ?? []).map(action => ({ label: action.label, step: { uses: action.uses, with: {} } }))]; + const beginDeletion = (next: PendingDeletion) => { + returnFocus.current = document.activeElement as HTMLElement | null; + setDeletion(next); + }; + const requestDelete = (job: string, step?: number) => beginDeletion({ job, step, revision: store.getSnapshot().revision, impact: store.deletionImpact(job, step).map(path => path.join('.')) }); + const requestEntryDelete = (path: Path) => beginDeletion({ path, revision: store.getSnapshot().revision, impact: store.entryDeletionImpact(path).map(item => item.join('.')) }); + useEffect(() => { + if (deletion && deletion.revision !== snapshot.revision) { + setDeletion(undefined); + setError('The workflow changed. Review the deletion again.'); + } + }, [deletion, snapshot.revision]); + useEffect(() => { + if (!deletion) return; + return () => queueMicrotask(() => { + const target = returnFocus.current?.isConnected ? returnFocus.current : root.current?.querySelector('.aw-inspector h2, .aw-toolbar button'); + target?.focus(); + }); + }, [!!deletion]); + useEffect(() => { + if (!root.current) return; + const observer = new ResizeObserver(([entry]) => setNarrow(entry.contentRect.width <= 760)); + observer.observe(root.current); + return () => observer.disconnect(); + }, []); + useEffect(() => { + const element = root.current; + if (!element) return; + for (const selector of ['.aw-toolbar', '.aw-mobile-nav', '.aw-workspace', '.aw-diagnostics']) { + const section = element.querySelector(selector); + if (section) section.inert = !!deletion; + } + for (const [selector, name] of [['.aw-main', 'canvas'], ['.aw-palette', 'palette'], ['.aw-inspector', 'inspector']]) { + const section = element.querySelector(selector); + if (section) section.inert = narrow && panel !== name; + } + if (narrow && panel === 'inspector' && !deletion) element.querySelector('.aw-inspector h2')?.focus(); + }, [narrow, panel, !!deletion]); + const remove = () => { if (!deletion) return; attempt(() => { + if ('path' in deletion) store.deleteEntry(deletion.path, true); + else if (deletion.step === undefined) { store.deleteJob(deletion.job, true); select({}); } + else { store.deleteStep(deletion.job, deletion.step, true); select({ job: deletion.job }); } + setDeletion(undefined); + }); }; + const download = () => { + const url = URL.createObjectURL(new Blob([snapshot.source], { type: 'application/yaml' })); + const link = document.createElement('a'); link.href = url; link.download = 'workflow.yml'; link.click(); setTimeout(() => URL.revokeObjectURL(url), 1000); + }; + return
{ + if (e.key === 'Enter' && (e.target as HTMLElement).tagName === 'INPUT') e.preventDefault(); + if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 's') { + e.preventDefault(); + save(); + } + // Text controls keep native local editing; CodeMirror uses the shared history directly. + if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'z' && !(e.target as HTMLElement).closest('input,textarea,.cm-editor')) { e.preventDefault(); e.shiftKey ? store.redo() : store.undo(); } + }}> +
⌘{w?.name || 'Workflow'}{store.readOnly ? 'Viewer' : 'Editor'}
+
{(['visual', 'yaml', 'split'] as const).map(value => )}
+
{!store.readOnly && <>}{!store.readOnly && options.onSave && }
+
+ + {snapshot.stale &&
Stale preview — fix the YAML structure to resume visual editing.
} + {(error || snapshot.saveError) &&
{error || snapshot.saveError}
} + {(!w || !snapshot.source.trim()) &&
{snapshot.source.trim() ? 'This YAML does not have a usable workflow yet.' : 'Start a workflow'}

Write YAML, or create a workflow with its first job.

{!store.readOnly && }{snapshot.source.trim() && The current source can be recovered with Undo.}
} +
+ +
+ {mode !== 'yaml' &&
setQuery(e.target.value)} />
+
nodes={nodes} edges={edges} nodeTypes={nodeTypes} nodesDraggable onNodesChange={changes => { + if (changes.some(change => change.type === 'dimensions' || change.type === 'position')) setGeometry(previous => { + const next = { ...previous }; + for (const change of changes) { + if (change.type !== 'dimensions' && change.type !== 'position') continue; + const existing = Object.hasOwn(next, change.id) ? next[change.id] : {}; + const value = change.type === 'dimensions' + ? { ...existing, measured: change.dimensions ?? existing.measured } + : { ...existing, dragging: change.dragging }; + Object.defineProperty(next, change.id, { value, enumerable: true, writable: true, configurable: true }); + } + return next; + }); + const selected = changes.find(change => change.type === 'select' && change.selected); + if (selected?.type === 'select') select(current => current.job === selected.id ? current : { job: selected.id }); + const positions = Object.fromEntries(changes.flatMap(change => change.type === 'position' && change.position ? [[change.id, change.position]] : [])); + if (Object.keys(positions).length) store.setLayout({ ...store.layout, positions: { ...store.layout.positions, ...positions } }); + }} nodesConnectable={!disabled} edgesReconnectable={false} deleteKeyCode={null} fitViewOptions={{ maxZoom: 1, padding: 0.2 }} fitView={!store.layout.viewport} defaultViewport={store.layout.viewport} minZoom={0.15} maxZoom={2} onNodeClick={(event, node) => { if (!(event.target as HTMLElement).closest('button')) choose({ job: node.id }); }} onPaneClick={() => select({})} onConnect={connection => attempt(() => store.connect(connection.source, connection.target))} onMoveEnd={(_, viewport) => store.setLayout({ ...store.layout, viewport })}>
+
} + {mode !== 'visual' &&
YAML source {snapshot.validating ? 'Validating…' : `${snapshot.diagnostics.filter(d => d.severity === 'error').length} errors`}
} +
+ +
+
d.severity === 'error')}>{snapshot.diagnostics.length} diagnostics{snapshot.validating ? ' · validating' : ''}
    {snapshot.diagnostics.map((d, i) =>
  • {d.severity} {d.location ? `${d.location.line}:${d.location.column} ` : ''}{d.path.join('.')} — {d.message} ({d.source})
  • )}
+ {deletion &&
{ + if (e.key === 'Escape') setDeletion(undefined); + if (e.key === 'Tab') { + const buttons = Array.from(e.currentTarget.querySelectorAll('button:not(:disabled)')); + if (!buttons.length) return; + e.preventDefault(); + const current = buttons.indexOf(document.activeElement as HTMLButtonElement); + const next = current < 0 ? (e.shiftKey ? buttons.length - 1 : 0) : (current + (e.shiftKey ? -1 : 1) + buttons.length) % buttons.length; + buttons[next].focus(); + } + }}>

Delete {'path' in deletion ? String(deletion.path.at(-1)) : deletion.step === undefined ? 'job' : 'step'}?

{'path' in deletion ? 'Expression references remain and receive diagnostics.' : 'Structural dependencies will be removed. Expression references remain and receive diagnostics.'}

{deletion.impact.length ? <>

Affected paths:

    {deletion.impact.map((path, i) =>
  • {path}
  • )}
:

No references found.

}
} +
; +} +export function WorkflowSurface({ store }: { store: WorkflowDocument }) { return ; } +function ReactWorkflow({ options, readOnly }: { options: WorkflowOptions; readOnly: boolean }) { + const latest = useRef(options); + latest.current = options; + // Inline callbacks are normal in React. Stable delegates avoid restarting + // validation (and notifying a controlled parent) merely because it rendered. + const delegates = useMemo(() => ({ + onChange: (snapshot: Parameters>[0]) => latest.current.onChange?.(snapshot), + onLayoutChange: (layout: LayoutState) => latest.current.onLayoutChange?.(layout), + onSave: async (snapshot: Parameters>[0]) => { await latest.current.onSave?.(snapshot); }, + validate: async (snapshot: Parameters>[0], signal: AbortSignal) => await latest.current.validate?.(snapshot, signal) ?? [], + }), []); + const current = { ...options, ...delegates, onSave: options.onSave ? delegates.onSave : undefined, validate: options.validate ? delegates.validate : undefined }; + const [store] = useState(() => new WorkflowDocument(current, readOnly)); + const hostSource = useRef(options.source); + useEffect(() => { store.start(); return () => store.destroy(); }, [store]); + useEffect(() => { + // Presentation/catalog updates must not reapply an unchanged initial source + // over local edits in an uncontrolled host. + const { source, ...next } = current; + if (source !== hostSource.current) { + hostSource.current = source; + store.update({ ...next, source }); + } else store.update(next); + }, [store, options.source, options.theme, options.tokens, options.agents, options.workflows, options.actions, options.layout, options.validationKey, !!options.onSave, !!options.validate]); + return ; +} +export function WorkflowEditor(options: WorkflowOptions) { return ; } +export function WorkflowViewer(options: WorkflowOptions) { return ; } diff --git a/ui/src/env.d.ts b/ui/src/env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/ui/src/env.d.ts @@ -0,0 +1 @@ +/// diff --git a/ui/src/expressions.ts b/ui/src/expressions.ts new file mode 100644 index 0000000..c3857e2 --- /dev/null +++ b/ui/src/expressions.ts @@ -0,0 +1,40 @@ +import type { Path } from './types'; +export interface Reference { scope: 'jobs' | 'needs' | 'steps' | 'inputs' | 'secrets'; id: string; output?: string; offset: number } +export function mapReferences(text: string, callback: (ref: Reference) => string | undefined, bareExpression = false): string { + const mapBody = (body: string) => body.replace(/"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|(? scope ? callback({ scope, id, output, offset }) ?? token : token); + // Conditions and repeat.until also accept expressions without interpolation + // delimiters. Other strings are prose unless enclosed in ${{ ... }}. + if (bareExpression && !text.trim().startsWith('${{')) return mapBody(text); + return text.replace(/\$\{\{([\s\S]*?)\}\}/g, (_expression: string, body: string) => { + return '${{' + mapBody(body) + '}}'; + }); +} +export function referenceText(ref: Pick): string { + return `${ref.scope}.${ref.id}${ref.output === undefined ? '' : `.outputs.${ref.output}`}`; +} +export function references(text: string, bareExpression = false) { + const result: Reference[] = []; + mapReferences(text, ref => { result.push(ref); return undefined; }, bareExpression); + return result; +} +export function pathJob(path: Path): string | undefined { return path[0] === 'jobs' ? String(path[1]) : undefined; } +export function isExpressionPath(path: Path): boolean { + return path[0] === 'jobs' && (path.length === 3 && path[2] === 'if' + || path.length === 4 && path[2] === 'repeat' && path[3] === 'until' + || path.length === 5 && path[2] === 'steps' && path[4] === 'if'); +} +// Keep literal metadata and callable defaults intact. These are the value +// fields rendered by executor.go and command.go; with mappings are recursive, +// including structured-output schema values passed through renderMap. +export function hasExpressionReferences(path: Path): boolean { + if (isExpressionPath(path)) return true; + if (path.length === 5 && path[0] === 'on' && path[1] === 'workflow_call' && path[2] === 'outputs' && path[4] === 'value') return true; + if (path[0] !== 'jobs') return false; + if ((path[2] === 'with' || path[2] === 'secrets') && path.length >= 4) return true; + if (path[2] === 'outputs' && path.length === 4) return true; + if (path[2] !== 'steps' || typeof path[3] !== 'number') return false; + return (path[4] === 'with' && path.length >= 6) + || (path[4] === 'argv' && path.length === 6 && typeof path[5] === 'number') + || (path.length === 5 && (path[4] === 'run' || path[4] === 'working-directory')); +} diff --git a/ui/src/forms.tsx b/ui/src/forms.tsx new file mode 100644 index 0000000..34419cf --- /dev/null +++ b/ui/src/forms.tsx @@ -0,0 +1,200 @@ +import { createContext, useContext, useEffect, useId, useRef, useState } from 'react'; +import { controllerParameters, dependencies, effectiveStepID } from './validation'; +import { parseDocument, stringify } from 'yaml'; +import type { WorkflowDocument } from './document'; +import type { ActionDefinition, Mapping, Path, Step } from './types'; +import { expressionSuggestions } from './source'; +import { hasExpressionReferences } from './expressions'; + +export type ApplyResult = { ok: true } | { ok: false; error: string }; +export function applyEdit(fn: () => void): ApplyResult { + try { fn(); return { ok: true }; } + catch (error) { return { ok: false, error: String(error) }; } +} +type InspectorDraft = { apply: () => boolean; reveal: () => void; identifier: boolean }; +export class InspectorDrafts { + private fields = new Set(); + register(field: InspectorDraft) { this.fields.add(field); return () => { this.fields.delete(field); }; } + apply() { + // Apply values before renaming their containing paths; nested identifiers + // precede the job identifier. A successful blur is already accounted for. + const fields = [...this.fields]; + for (const field of [...fields.filter(field => !field.identifier), ...fields.filter(field => field.identifier).reverse()]) { + if (!field.apply()) return field; + } + } +} +export const InspectorDraftContext = createContext(undefined); + +export function Field({ label, value, onApply, readOnly, multiline = false, type = 'text', suggestions, suggestionValues = [], identifier = false }: { + label: string; value: unknown; onApply: (value: string) => ApplyResult; readOnly: boolean; + multiline?: boolean; type?: string; suggestions?: string; suggestionValues?: string[]; identifier?: boolean; +}) { + const text = value == null ? '' : String(value); + const [draft, setDraft] = useState(text); + const [error, setError] = useState(''); + const control = useRef(null); + const current = useRef(text); + const applied = useRef(text); + const errorID = useId(); + const registry = useContext(InspectorDraftContext); + const cursor = useRef<{ start: number; end: number } | undefined>(undefined); + const updateDraft = (next: string) => { current.current = next; setDraft(next); }; + const discard = () => { updateDraft(text); applied.current = text; setError(''); }; + useEffect(() => { + // A sibling field can change this field's stored subtree. Keep its pending + // draft until it is applied or explicitly discarded. + const pending = current.current !== applied.current; + applied.current = text; + if (!pending || readOnly) { current.current = text; setDraft(text); setError(''); } + }, [text, readOnly]); + const apply = () => { + if (readOnly || current.current === applied.current) { setError(''); return true; } + const result = onApply(current.current); + if (!result.ok) { setError(result.error); return false; } + applied.current = current.current; setError(''); return true; + }; + const latestApply = useRef(apply); + latestApply.current = apply; + useEffect(() => registry?.register({ identifier, apply: () => latestApply.current(), reveal: () => { + for (let parent = control.current?.parentElement; parent; parent = parent.parentElement) { + if (parent instanceof HTMLDetailsElement) parent.open = true; + } + requestAnimationFrame(() => { control.current?.focus(); control.current?.closest('.aw-field')?.scrollIntoView({ block: 'nearest' }); }); + } }), [registry, identifier]); + const props = { + 'aria-label': label, 'aria-invalid': !!error, 'aria-describedby': error ? errorID : undefined, value: draft, readOnly, + ref: (element: HTMLInputElement | HTMLTextAreaElement | null) => { control.current = element; }, + onChange: (event: React.ChangeEvent) => updateDraft(event.target.value), + onBlur: () => { apply(); }, + onKeyDown: (event: React.KeyboardEvent) => { + if (event.key === 'Escape') { event.preventDefault(); discard(); } + if (event.key === 'Enter' && !multiline) { event.preventDefault(); event.currentTarget.blur(); } + }, + }; + return