feat(a11y): Accessibility Studio portlet + AI agent platform (@dotcms/ai, MCP server, agent UI) - #36641
Draft
fmontes wants to merge 139 commits into
Draft
feat(a11y): Accessibility Studio portlet + AI agent platform (@dotcms/ai, MCP server, agent UI)#36641fmontes wants to merge 139 commits into
fmontes wants to merge 139 commits into
Conversation
Tell the model that binary file-asset endpoints (e.g. /api/v2/assets,
/dA) return a { __dotcmsBinary, contentType, base64, byteLength }
envelope whose base64 is the raw bytes to decode — not text.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Master plan (a11y-agent-plan.md) and per-session briefs (S0-S5) for the dotCMS accessibility-fix agent. Includes the S0 spike outcomes: the loop composes through @dotcms/agentic-tools, the minted JWT is accepted on all four endpoints, and the EDIT_MODE-vs-EDIT_MODE re-scan basis. Captured real response shapes in S0-captured-responses.json as the reference S1 codes the report schema against. Gitignore .env and the scratch/ throwaway. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Generic agents-host service (core-web/apps/dotcms-agents), sibling to mcp-server; the a11y-fix agent is its first capability (S1). @nx/node application, framework=none, esbuild/cjs, jest, eslint. Hono + node-server for the HTTP surface (no Nx plugin needed — Nx bundles/serves the TS entry, Hono is a plain import). Sets moduleResolution=node in tsconfig.app.json (the base bundler resolution is incompatible with module=commonjs). Verified: build succeeds and GET /health returns ok. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The §6 report and §8.2 request schemas — the seam S2 (proxy) and S3 (Studio) build against. Statuses locked to the five-value vocabulary (fixed-to-working | reported | skipped | regressed | failed); publishRequired is z.literal(true) so the agent can never report a publish. Tests validate the §6 plan example verbatim and assert the locks (status set, hostId required, publishRequired true, non-negative counts). Also the §8.7 active-run slot schema. contract.ts at 100% coverage. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…§3-B) withAllowlist() wraps the agentic-tools api adapter so only the four loop operations reach the wire: page-scanner/a11y/check POST, _render-sources GET (prefix), /api/v2/assets GET, /api/v2/assets/save PUT. Everything else — publish, delete, workflow, config — is rejected before fetch, even under prompt injection. The /save vs /publish distinction is enforced by exact-match (a prefix rule would admit /publish). The wrapper never sees the auth token (it lives in the inner execute's closure). 16 tests incl. the DoD publish-path rejection and proof the token-bearing inner is never reached on a disallowed call. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
runFix() drives SCAN(live) → SCAN(EDIT_MODE baseline) → LOCATE → READ → per-violation TRIAGE → FIX → SAVE-WORKING → RE-SCAN → REPORT. Shape (B): the LLM is scoped to two structured calls (triage+attribution, minimal diff via AI SDK generateText+Output.object); all sequencing, guards, caps and §6 report assembly are plain code so the guards are testable paths. Guards (each tested): refuse-if-dirty (working≠live → skipped, no save), attribution-evidence gate (no edit unless the read file contains the offending markup), auto-revert-on-regression (re-scan worse than the EDIT_MODE baseline → revert + regressed), 0-byte save → failed, per-run caps (files/bytes/violations). Re-scan basis is EDIT_MODE-vs-EDIT_MODE (S0: chrome adds phantom violations). DotcmsClient wraps the 4 calls through the allowlist-guarded sandbox; triage/fix are injectable for tests. 36 tests green, typecheck + lint clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Wires the loop to the front door (plan §8.2/§8.7). POST /a11y/fix reads the token from Authorization: Bearer (never the body), validates against the locked FixRequestSchema (401 no-token / 422 bad-body / 502 run-failure), runs runFix, returns the §6 report as JSON. GET /a11y/active-run returns the calling user's slot. Per-user ActiveRunRegistry keyed by the JWT sub claim (decoded, not verified — verification is the proxy's job, S2); stale-run finishes don't clobber a newer slot (replace-on-retrigger). Build switched to bundle:true so the agentic-tools spec.json is inlined (unbundled output failed to require the generated json at runtime). Verified live: health ok, 401/null/401/422 on the endpoints as expected. 13 new tests (auth helpers, routes, registry); 49 total green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
E2e against the real demo /index surfaced two issues: 1. refuse-if-dirty misfired on the agent's OWN in-run edits — after the first fix to a file, the next violation in that same file saw working≠live and skipped, cascading to 5 false skips. Removed the guard entirely: the goal is to fix the a11y issue, and working-save is non-destructive (dotCMS per-asset version history, §3). The loop now keeps one progressively- improved working copy per file (currentContent); later violations build on earlier edits. Plan §5/§6/§12 updated to record this as an accepted v1 tradeoff (concurrency safety remains GA debt). 2. CSS contrast — the most common violation class — was unreachable: LOCATE only surfaced VTLs, so every contrast issue reported "rule lives in styles.dotsass, not a candidate." _render-sources now returns theme.css + theme.js; collectCandidates includes them. Re-run read 13 files (was 9) and generated a fix directly in styles_precompiled.css. Verified live: dirty cascade gone (2→5 real fixes), CSS source reached. API-error path also confirmed (out-of-credits → failed status with reason, no crash). 49 tests green; refuse-if-dirty test replaced with a same-file- builds-on-previous-edit test. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Opus cost ~$12 for a single page in e2e testing — far too expensive for triage classification and minimal diffs. Two cost fixes: - Default model Opus 4.8 → Sonnet 4.6 (~5x cheaper), overridable per deploy via A11Y_AGENT_MODEL with no code change. Model stays injectable per call. - Prompt-cache the triage candidate-files block. It is identical for every violation in a run but was re-sent in full each time (the dominant cost — 13 files incl. large CSS, x20 violations). Moved it to a cacheable ephemeral user message; the per-violation details go in a separate uncached message. ~10x cheaper on the repeated prefix after the first call. Together these should bring a page from ~$12 toward ~$1. 49 tests green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
setUsageSink() lets tooling total token usage (input/cached/output) across every triage + fix call in a run without threading usage through signatures. Unset in production (zero overhead); the e2e harness registers it to log per-call tokens and estimate run cost. Both generateText calls now report their usage through it. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
collectCandidates no longer includes theme JS. The agent doesn't edit JS, and theme JS bundles are large (on the demo theme core.min.js alone is ~249K chars / ~62K tokens) and dominated triage token cost. JS-injected DOM issues are still surfaced — handled via report-only triage. Candidate set on the demo /index drops 13→11 files, ~64K tokens (~33%) off every triage call. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…itelist The theme block split files into hardcoded vtls/css/js buckets, which misses the many other extensions a theme legitimately uses (.scss, .sass, .dotsass, .less, ...). Whitelisting types in the API is the wrong layer. Return every file under the theme folder in a single files[] list, each carrying its lowercased extension, and let consumers filter by type. buildThemeView no longer matches on extension at all; FileRefView gains an `extension` field (also populated for widget file refs). a11y-agent side: collectCandidates now keeps theme files whose extension is in an editable set (vtl + stylesheet preprocessors) and the save-content-type helper treats all stylesheet extensions as text/css — so .scss/.sass/.less are picked up automatically with no further code. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…dated) Sending whole stylesheets to the LLM cost ~$1-12/page and doesn't scale. A spike validated deterministic attribution: parse CSS (postcss) + match the offending element against rule selectors (css-select) + rank by specificity, sending the model only the winning rule(s) — 34,007 → ~106 tokens (99.7%), scale-independent. Two residual constraints captured: sound matching is pure-compound-only (the scan gives the element, not ancestors), and fixes must edit the SCSS source, not the compiled artifact (regenerated on compile). - New session brief S1.5-css-attribution.md (module + wire-in + compiled→SCSS mapping + sound-matching guard + contrast-math option). - README: S1.5 in table + dependency graph; replaced the stale refuse-if-dirty convention (removed in S1) with the no-whole-CSS / edit-source conventions. - Plan: new §3 "CSS attribution" decision row; §5 TRIAGE/READ/FIX updated; §9 risk entry (validated, with the two constraints); §10 Phase 1 step 4b; status line reflects S0/S1 done, S1.5 spiked. Spike artifacts live in core-web/scratch/ (gitignored): SPIKE-css-attribution.md + css-attribution-proto.ts. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
defaultModel() now picks the provider from env (plan §3 — provider not locked, no loop rewrite to swap): A11Y_AGENT_PROVIDER = anthropic (default) | openrouter A11Y_AGENT_MODEL = provider-appropriate model id OPENROUTER_KEY / OPENROUTER_API_KEY = key when provider=openrouter Uses @openrouter/ai-sdk-provider (createOpenRouter().chat(model)). Verified live: anthropic/claude-sonnet-4.5 via OpenRouter returns valid structured output (Output.object path the loop relies on); OpenRouter also reports per- call cost in usage.raw.cost. Default behavior unchanged (Anthropic Sonnet). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Switches the default provider/model to OpenRouter + kimi-k2.7-code. Structured output (Output.object) verified working through it, and it's ~11x cheaper per call than Sonnet on the triage/diff workload ($0.00008 vs $0.0009 on a smoke call). Still env-overridable (A11Y_AGENT_PROVIDER / A11Y_AGENT_MODEL); anthropic path unchanged when selected. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… provider The triage file-context part hardcoded providerOptions.anthropic.cacheControl, which is meaningless on OpenRouter and can trip its response parser. Gate it on DEFAULT_PROVIDER === 'anthropic'. (Native-Anthropic prompt caching unchanged.) Note: this is not what blocks the OpenRouter e2e — that fails because the loop still sends the whole theme source tree (154 files ≈ 217K tokens) per triage, over Kimi's context limit. The provider itself is verified working (smoke + small real triage call succeed); the fix is S1.5 CSS attribution / not sending all files. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Real themes have 150+ SCSS partials (demo: 154 editable files ≈ 217K tokens). The crux isn't model context size — handing a model the whole tree is the wrong operation at any size (cost, accuracy, speed, doesn't generalize); it's now a hard failure on tighter-context models (Kimi: "Provider returned error"). Lock the loop to be LAZY and per-violation — NEVER pre-read the theme tree: attribute against the one compiled stylesheet → winning rule (~100 tokens, the only thing the LLM sees) → sourcemap (now shipped/validated) → read only the one SCSS source file → edit. Partial count becomes irrelevant (~1 stylesheet + ~1 source file per fix). - S1.5 brief: new "Core principle — NEVER pre-read the theme tree" section; tasks reordered (rip out collectCandidates pre-read; lazy CSS path; sourcemap resolution now that the endpoint is live); entry state updated (sourcemap shipped, deps, OpenRouter+Kimi default). - Plan §5 READ step rewritten as lazy/per-violation with the partial-count rationale. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…n validated
Scanner now returns applied stylesheets[] at the scan-response root (option B),
so the agent picks the compiled stylesheet from what the page actually loaded
(filter same-origin, drop CDN/fonts) — no name-guessing, handles multiple
sheets/any extension.
Validated the FULL lazy chain live, end to end, zero partials read, no LLM:
scan.stylesheets → styles.dotsass → fetch ?sourcemap=true → postcss+css-select
for #book → .button-primary{background-color:#e76300} → sourcemap value-column
→ custom-styles/_variables-custom.scss:64 = $primary:#E76300.
Also captured the sourcemap-extraction gotcha (URL-encoded payload contains '*',
so a non-greedy regex breaks; use indexOf marker → first comma → last '*/' →
decodeURIComponent). S1.5 brief + sourcemap spec updated.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Full-screen Accessibility Studio portlet at libs/portlets/dot-accessibility-studio, registered at /accessibility-studio. - Page picker: real /api/content/_search via DotContentSearchService (host-scoped pages + urlmaps, debounced search, p-table pagination). - Studio run screen: score widget, agent recipe log, state-driven action footer (scan/fix/publish/discard), iframe preview. Run is MOCK data based on the agent §6 FixReport contract — no SSE/overlays/animation yet (S4/S5). - SignalStore drives the phase state machine (picker→ready→scanning→scanned→fixing→done→published). - PrimeNG + Tailwind, dotCMS primary token, i18n keys, data-testid. - Tests: 36 passing (store query/search/state machine, picker, run screen). Menu guards temporarily removed from the route for local iteration. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…oundation) Two pure, deterministic modules (no LLM, no network) — the building blocks of the lazy CSS-fix path. Built in parallel, hardened from the validated spike. css-attribution.ts: - parseColorRules(css) → color rules (postcss AST), keeps the node + position - attribute(elementHtml, rules) → matching rules ranked by specificity - SOUND matching only: pure compound selectors (combinator selectors excluded — the scan gives the element, not its ancestors; rightmost-compound fallback false-positives). Dynamic pseudos stripped for the match, kept in output. css-source-map.ts: - extractInlineSourceMap(css) → parsed v3 map; robust extraction (indexOf marker → first ',' → last '*/' → decodeURIComponent) handling the literal-'*'-in- payload gotcha - resolveSource / resolveDeclarationValue → map a compiled decl's value column back to its SCSS source file+line (lands on the $variable) jest.config.cts: transformIgnorePatterns whitelist for the pure-ESM deps (css-select/htmlparser2 + transitive) so their specs run under ts-jest. 24 new tests; 73/73 pass, tsc + lint clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The loop no longer pre-reads the theme tree. processViolation now routes: - CSS (color-contrast): deterministic path — pick the applied stylesheet from scan.stylesheets[] → fetch the ONE compiled sheet (+inline sourcemap) → attribute the rule in code (css-attribution) → map the decl's VALUE column back to its SCSS source via the sourcemap (lands on $primary in _variables-custom.scss) → LLM sees ONLY the matched rule (~300 tokens) → edit that one source file. The 150+ SCSS partials are never read or sent. - VTL: small candidate set (theme + container VTLs), read lazily, LLM triage+fix. saveAndRescan() shared by both (save-working, verify bytes, EDIT_MODE re-scan, auto-revert on regression). New: client.fetchStylesheet (compiled CSS+map, absolute→relative URL), allowlist entry for GET /application/themes/ (read-only theme assets), triage.generateColorFix (rule-scoped color nudge). Resolves the decl VALUE position (not the rule/selector position, which traces to the mixin) and edits the real source token (#E76300), not the compiled value (#e76300). Verified live on demo /index with OpenRouter+Kimi: per-fix payload ~298 tokens (was 217K → "Provider returned error"), fixes resolve to _variables-custom.scss and land; auto-revert correctly fires on a shared-token regression. 73 tests green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…tion) A single shared-rule CSS edit clears many violations, so the loop now reuses the ONE re-scan saveAndRescan already does (no extra scan per violation) to refresh which violations remain, and skips collateral-cleared ones. Honest reporting: a vanished violation is only credited 'fixed-to-working' if we actually edited a source for that SAME rule code; otherwise 'reported' (scan variance), so we never over-claim. Also cap maxOutputTokens (Kimi over-generated to 24k on a one-line color fix, stalling runs): triage 2048, color-fix 2048, file-fix 8192. Validation status: machinery proven end-to-end (attribute → sourcemap → correct SCSS file → surgical edit → save → re-scan; ~48s, ~$0.002). KNOWN GAP: fixes attribute .btn/button contrast to a generic `a:focus` rule and don't actually clear the violation (scan count unchanged) — an attribution- accuracy problem to debug next, not a wiring problem. 73 tests green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The scanner (axe) measures contrast in the element's RESTING state, but
attribution was stripping :hover/:focus/:active and then MATCHING those rules
— so `a:focus` outranked the real resting `a`/`.btn`/`.button-primary` rule
and the agent edited a state that doesn't apply (fixes never cleared the
violation). Now any selector with a state pseudo-class or pseudo-element
(:hover/:focus/:active/:visited/:target/:focus-*/::before/::after/…) is
EXCLUDED from matching.
Verified against the real theme: <a class="btn"> now attributes to
`a { color }` (resting), <a class="button-primary"> to `.button-primary`
(ranked above `a`) — no more :focus mis-attribution. 73 tests green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…agent)
The scanner now returns a pure axe result (axe.{violations,incomplete,…} +
stylesheets) and no longer carries findings/counts — which the agent read, so
the agent was out of sync with the live scanner. Per the "agent owns
normalization" decision, DotcmsClient.scan() now maps raw axe → the internal
ScanResult: each axe violation RULE expands to one finding per flagged NODE
(contrast rule w/ 14 nodes → 14 findings), incomplete → needs-review, and
crucially the per-node check `data` (fgColor/bgColor/contrastRatio/
expectedContrastRatio) is carried onto each finding. passes/inapplicable are
ignored. The rest of the loop is unchanged (ScanResult shape preserved).
This unblocks deterministic contrast fixing next: with finding.data the agent
can attribute by exact fgColor and compute the WCAG nudge in code (no LLM).
6 normalizeAxe tests; 79 total green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
With the scanner returning axe's per-node data (fgColor/bgColor/ratio/target), the contrast fix is now pure WCAG math instead of an LLM call — removing Kimi from the contrast path (it returned empty/runaway output on the structured color fix) and making each fix mathematically guaranteed to clear. New contrast.ts (no dependency): parseColor, relativeLuminance, contrastRatio, parseTargetRatio, nudgeToClear (binary-search the minimal hue-preserving lightness nudge; evaluates the ROUNDED hex so 8-bit quantization can't land just under threshold; returns null when unreachable → reported). processCssViolation: attribute the rule → resolve the SCSS source via sourcemap → take the editable color (attributed decl value) + its counterpart from finding.data → nudgeToClear → replace the source token → save → re-scan. The diff records before/after ratio. generateColorFix LLM path removed from runFix. 13 contrast tests; 89 total green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ecificity The CSS path picked the editable decl by specificity ranking, then paired it with axe's fg/bg by property name — which mis-paired when the attributed rule's color didn't match what axe actually measured (e.g. nudging a stale `a` color against an unrelated blue bg → "cannot reach 4.5:1"). Now axe's data is the source of truth: among the element's matched rules, pick the (rule, decl) whose value EQUALS axe's fgColor or bgColor; the other color of the pair is the counterpart. If no matched rule's color equals the flagged pair, report honestly (the failing color is inherited/inline/computed, not in an attributable rule) rather than edit the wrong thing. findNamedColorDeclNode resolves the exact decl node (a rule may have several color decls). 89 tests green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…MODE The studio preview iframe now loads the selected page same-origin through the `/dot-page` dev-proxy sentinel (apps/dotcms-ui/proxy-dev.conf.mjs strips the prefix and forwards to the BE page renderer), instead of pointing at the Angular dev server origin. - previewUrl → `/dot-page<path>?host_id=&language_id=&mode=EDIT_MODE` (§8.2 working-version render the agent re-scans). - Thread the host identifier (StudioPageRow.hostId from contentlet.host) so host_id disambiguates which site's copy of the path renders. - Add the `/dot-page` proxy rule for the iframe. - Tests updated; 36 passing. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The external scanner now returns raw axe-core data (axe.violations / axe.incomplete) instead of the normalized findings/issues envelope. Remodel the service types, map rules to display groups (one rule per group, nodes as items), derive error/warning counts in the component, and drop the now-nonexistent "notices" summary card. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…allback) When the editable color is the foreground white text, nudging it can't clear against a light background (white is already maximal) — the agent gave up. Now it builds BOTH candidate edits (the decl matching fgColor → nudge vs bg, and the decl matching bgColor → nudge vs fg) and tries them in order, keeping the first that actually yields a fix. So white-text-on-light-bg now falls back to darkening the background instead of reporting unfixable. Still exact-color-match only (no guessing); reports honestly if neither side is nudgeable/reachable. 89 tests green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…tput Kimi K2.7 returned empty/malformed structured output on the triage schema (4/14 violations failed as "No output generated"). Switched the default OpenRouter model to z-ai/glm-5.2 — reliable structured output, good triage reasoning, low cost (verified). Also added withRetry() around the structured generateText calls: LLMs intermittently return empty/unparseable objects even on tiny prompts (a transient hiccup), so retry up to 3x on exactly those errors (No output generated / did not match schema / could not parse) before giving up; real errors still rethrow immediately. Model still env-overridable via A11Y_AGENT_MODEL. 89 tests green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
display:contents collapsed the <dot-a11y-diff> host but PROMOTED its <p-drawer> child to a direct grid item of the run screen's grid — the always-present drawer element then claimed a phantom third track and skewed the two-column preview layout (even while closed). Switch the host to `position: fixed`, which takes the host and the drawer it renders out of grid flow entirely. The p-drawer still positions its mask/container against the viewport as usual. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The button was gated on changedFiles().length > 0, so a run that reported "0 fixed to working" hid it — even though the working version may still differ from live. Gate on done/published only; the diff panel resolves the working-vs-live delta itself and shows its own empty state when identical. Drop the now-meaningless count from the button label. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…mask PrimeNG's drawer keeps its fixed-position overlay mask latched (modalVisible clears only after the leave animation) which left a p-drawer-mask covering the run screen. Wrap the entire <p-drawer> in @if (open()) so there is zero drawer DOM — and no mask — when closed; it mounts fresh on open. Drops the now-redundant local `visible` signal and open→visible sync effect: inside the @if the drawer is always [visible]="true", the X button and the drawer's own dismiss paths both funnel to the `close` output, and the host clears `open` to unmount it. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ntent
PrimeNG force-moves the drawer's mask to document.body but keeps the
container inline by default. Inside the diff component's position:fixed
host — an isolated stacking context — the body-level mask (z 1103) painted
ON TOP of the inline container (z 1104), covering the whole panel including
the X button (verified: elementFromPoint at the X returned the mask).
Set appendTo="body" so the container is a sibling of the mask in the same
stacking context; their z-indexes then order correctly and the panel is
interactive. Verified end-to-end in the browser: open → mask dims behind,
panel on top; X click closes cleanly with no orphaned mask; reopen works.
Diff spec queries now use { root: true } since the drawer content is
teleported to document.body.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The diff drawer compares source files side by side, so it needs the space. Bump its width from min(92%,1100px) to 80vw. Verified live: 1210px on a 1512px viewport (80%). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…drawer The code diff now lives in the run screen's right column alongside the visual preview, toggled by a Preview/Code pill toolbar — so the visual before/after and the source before/after share the same space and can be lined up against each other. - DotA11yDiffComponent: dropped p-drawer entirely; it's now a plain inline panel (file list + Monaco) filling the Code tab. `open`/`close` replaced by an `active` input that drives the lazy load + Monaco layout (the host has no size while the Preview tab shows). - Run screen: right column is now [pill toolbar][swap area]; Preview shows the two iframes (kept mounted/hidden so they don't reload on toggle), Code mounts <dot-a11y-diff> on first visit. `previewTab` signal; Code disabled until done/published (with hint) and force-reverts to Preview if it becomes unavailable (Discard/Re-scan). Removed the footer "View file changes" button and the drawer open/close wiring. - i18n: preview.tab.preview/code + code.hint; removed diff.view/close/title. Verified live: pills render, Code disabled pre-run, toggling hides the preview iframes and shows the inline diff in the same space; agent column preserved. 139 tests, lint, AOT build all green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The diff panel resolves the working-vs-live delta itself and shows a "No file changes" empty state when they match, so there's no reason to gate the Code tab on a completed run. Remove the codeTabEnabled gate, the disabled/hint bindings, the revert-to-Preview guard effect, and the now unused code.hint i18n key. Verified live: Code tab is clickable in the ready phase and shows the diff (empty state) in the same space as the preview. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
After a run completes, the agent footer now shows a single "Review changes" button instead of Publish — it opens the Code tab, and publishing happens only from there, so the user must review the working-vs-live diff before anything reaches live. - Run footer (done phase): Discard+Publish replaced by a full-width "Review changes" button → switches to the Code tab. - Code view: a review bar (Discard + Publish) at the bottom of the file list, shown while the run is in the done phase and there are changes. Publish is DISABLED until the user opens at least one file's diff (reviewed signal, set on selectFile) — no auto-select, so landing on the tab doesn't count. Publish promotes the whole working version to live (this run's fixes plus any prior-run or manual working edits). - Diff reload now keyed on page identifier + previewRevision, so a new run's changes (and manual/prior working edits) refresh when re-opened. - Run effect switches back to Preview once the phase leaves done (published/scanned), so the user isn't parked on a stale diff. - i18n: action.review, diff.publish, diff.review.hint. Verified live end-to-end: review button → Code tab; Publish disabled with hint until a file is opened, then enabled; Publish → store.publish() → published + back to Preview. 144 tests, lint, AOT build all green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Two changes to the proxy:
hostId is now required at the top level of the agent payload, alongside
runId and dotcmsBaseUrl. It comes from the already-resolved PageInfo, so
there's no extra lookup. It is also kept inside `page`, since the agent's
page object still carries it.
Upstream errors are no longer ingested. forwardJson dropped the 401/403
branch that replaced the agent's response with a synthesized
A11Y_AGENT_AUTH_FAILED envelope, and the SSE relay now forwards the
agent's own error body as the terminal `error` frame instead of
"Agent returned <status>". The agent owns its error shape; only failures
that never reached it (unreachable, not-configured, page-not-found,
token-mint, empty body) are still synthesized here.
A non-JSON error body is wrapped as {"message": "<text>"} because
DotAgentRunService parses the frame as JSON and reads .message — relaying
raw HTML would collapse to a generic "Agent run failed." and lose the
agent's actual text.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…iew pane Replaces the Preview/Code tab strip with a changed-files list in the run screen's left panel: - DotA11yDiffComponent is now the file list only — a collapsible accordion section that separates the files from the scanner output above it. It loads _render-sources as soon as the page opens, so pre-existing working edits show before any scan; empty state says "No files changed". - DotA11yDiffViewerComponent (new) owns the Monaco side-by-side diff and fills the right pane in place of the preview when a file is picked. - Two ways back to the preview: "Back to preview" in the side panel and a close arrow in the viewer header. The run screen owns the selection and feeds it down via activeFileId, so the highlighted row tracks the pane whichever control is used. - The preview iframes stay mounted (display toggled inline, since Tailwind's `hidden` ties with the `grid` class on the same element) so returning to them doesn't reload. - The done-phase footer is now [Discard] [Apply these changes]; Apply publishes the page, which publishes its changed source files together — there is no per-file publishing. This drops the old gate that required opening a file before publishing, now that the list is always visible. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The run screen's left column is now an accordion of two independent panels, each owning the actions for the content it holds: - Scanner: score ring, issue list, activity log, needs-review section, and the phase-driven scan/stop/re-scan/fix controls. - Files: the changed-file list plus "Publish page" — publishing the page publishes its changed source files together, so the action belongs with the files rather than in a shared footer. Both panels open and close independently, so a run can be watched while reviewing the files it touched. The scanner starts open, files collapsed. Open state is a Set rather than a single value; "Review files" in the done phase calls openPanel() (not togglePanel) so pressing it twice can't close the panel it just opened. Layout: both sections size to content and the accordion column owns the single scroll container. The recipe log's own overflow-y-auto is gone — a nested scroll area would trap the wheel and make the second panel hard to reach with both expanded. DotA11yDiffComponent drops its own accordion chrome (the run screen owns the header, count badge, and publish action) and emits changedCount so the panel header can badge it and gate Publish. The list stays mounted while collapsed so it keeps resolving the working-vs-live delta and the badge is accurate before the panel is ever opened. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
- Swap the hand-rolled accordion for p-accordion / p-accordion-panel / -header / -content in `multiple` mode. PrimeNG owns the open state via [(value)], so openPanels is now a StudioPanel[] and togglePanel() is gone; openPanel() stays for the "Review files" jump (non-toggling, so a second press can't close the panel it just opened). - Discard now sits next to Publish page in the files panel, with the files they act on. The scanner panel's done phase is just a jump to that panel. - Drop icons and [rounded] from the labelled action buttons. The re-scan button was icon-only, so it gains a visible label in place of its icon + tooltip; the two genuinely icon-only buttons (page-context back, diff viewer close) keep their icons since they have no label. - Restore the rule separating the two panels via [dt] rather than a ::ng-deep override. The app-wide CustomLaraPreset flattens accordions with panel.borderWidth: '0' and leaves per-feature dividers to the consuming component, so this sets width + gray-200 color scoped to this accordion. p-accordion-content keeps collapsed content mounted (hideStrategy "visibility"), so the files list still resolves the working-vs-live delta while collapsed and the header badge is accurate before first open. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ter a run The files panel's Discard + Publish now show for any changed files rather than only in the done phase. Working changes can predate the current run — an earlier run, or a manual edit — so they must be publishable without scanning first. That required unpicking the store's phase coupling, or the buttons would have rendered dead: - publish() early-returned unless phase === 'done'. It's now blocked only while isWorking() (a scan or fix in flight), where the working copy is still being written. - discard() hard-set the phase to 'scanned', which is wrong from 'ready' — it would show a results view for a scan that never ran. It now returns to 'scanned' only when a scanResult exists, otherwise 'ready'. Also includes spacing/border tweaks to the side panel: the recipe log trades its padding for a bottom margin, the ready card takes a surface-200 border, and the scanner action footer loses its divider and fill. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…alls Quality pass over the a11y studio — no behavior change. Store: buildA11yGroups(scanResult()) ran 8 times per scan result alongside the already-memoized a11yGroups computed. Hoisted a11yGroups/errorGroups/ warningGroups/elementCount as locals inside withComputed (sibling computeds in the returned literal can't reference each other), so seven computeds now read one memoized traversal. The error-filter-and-sum that was written out three times collapses to elementCount(errorGroups()). reviewGroups and issueTypeRows spread before sorting so they don't mutate the shared arrays. Added SEVERITY_RANK beside SEVERITY_ORDER, replacing a linear indexOf per comparison in issueTypeRows' sort comparator. Run component: severityColorFor() and reviewReasonKey() were called from @for loops, so they re-ran per row on every change-detection pass — and this component's CD is driven by a live SSE stream plus a rAF count-up. Both are now projected into issueTypeRows/reviewRows computeds, mirroring the existing severityRows pattern, and the methods are gone. Removed the diff component's spec-only hasChanges computed; its two specs now assert the changedCount output the parent actually consumes. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two related cleanups to the AccessibilityStudioStore: 1. Drop the dead `changedFiles` state. Nothing reads it anymore — the diff panel derives its file list from _render-sources + content/versions, not from the run report — so it was written (initialState, resets, the `workingChanged` SSE case, the done/aborted merge) but never consumed. Removed the field, its writes, and the now-unused AgentChangedFile import. The model types (FixReport.changedFiles, the workingChanged event) stay, since the agent still emits them over the wire. 2. Replace the per-phase boolean computeds (isReady/isScanning/isScanned/ isFixing/isDone/isPublished/inPicker/inStudio/scanned) with direct `phase()` comparisons at the call sites, keeping only the phase-SET computeds that carry meaning an enum compare can't express: isWorking, hasResults, runStarted, finished. `phase` is now the single interface for single-state questions. Migrated all consumers (run component ts+html, picker) and their specs. 160 tests, lint, and the dotcms-ui AOT build all green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The picker (agents/a11y) and run (agents/a11y/<page-path>) screens are two
routes that shared one AccessibilityStudioStore at the root. Split it so each
route owns independent, route-scoped state.
- store/a11y-picker.store.ts (A11yPickerStore): pages list + filter +
pagination, provided at the picker component. Its onInit reload effect no
longer needs the phase==='picker' guard — the store only exists on the
picker route.
- store/a11y-run.store.ts (A11yRunStore): the selected page + scan/fix/report/
diff lifecycle and all its computeds, provided at the run component. A new
instance per page navigation → fresh state, no manual reset. Removed the
backToPicker() store method: leaving the route destroys the store.
- Root component no longer provides or injects a store; scanner/agent service
providers moved to the run component. Diff injects A11yRunStore up the DI tree.
- Dropped 'picker' from StudioPhase — the picker is a separate store, so the
run machine starts at 'ready' and nothing compares against 'picker'. The
picker's on-entry reset block (its only run-domain coupling) is gone.
- Routes unchanged ('' + '**'); added a comment on why '**' (the page path is
multi-segment/human-readable, so it can't be a single :id param).
- Split the store spec into picker + run store specs; updated the 3 component
spec store mocks (drop backToPicker/phase/openPage where no longer used).
Verified live: picker lists 25 pages; click → run route (fresh store); back →
picker; cold deep-link + hard reload rehydrates the page via openPageByUri.
159 tests, lint, and the dotcms-ui AOT build all green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The review row's rule description used `truncate`, which needs a resolvable width — but inside PrimeNG's accordion content (a flex item that sizes to its content) the long unbreakable text set the intrinsic width and made the whole 412px side panel scroll horizontally. Swap `truncate` → `wrap-break-word` on the message + code so the text wraps within the panel. One Tailwind class, no PrimeNG overrides. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Regression from the store split: openPageByUri short-circuited on same path regardless of site, so switching sites while on the run screen left the stale page loaded. (The old shared store reset `selected` on site change; the run store had no such handling.) Gate the no-op on path AND host: skip only when the same page under the same site is already selected. The run component's rehydrate effect already tracks currentSiteId, so a site switch now re-resolves the same path against the new site — reloading the run (or bouncing to the picker if the page doesn't exist there). Verified live: switching demo.dotcms.com → awazon.local reloads the run screen to the new site's /index. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Match the look-and-feel from #36881, which drops custom accordion styling in favor of PrimeNG defaults: - Headers use a plain <span> (PrimeNG default typography) instead of the custom uppercase/tracking/gray classes; removed the scanner/files count badges. - Zero PrimeNG's content padding via [pt]="{ content: { class: '!p-0' } }"; the scanner body keeps its self-padded children flush, the files body adds its own px-4 pb-4 wrapper (its list doesn't self-pad). - Dropped the [dt] panelTokens border override — the accordion uses the theme's default panel dividers. Kept our multi-panel structure (scanner + files in one <p-accordion multiple>); only the visual look changed. The changed-file count is still tracked (it gates the publish bar) — its spec now asserts the signal instead of the removed badge. 160 tests, lint, and the dotcms-ui AOT build all green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…l fonts - Skip-CSS is an agent FIX option (report CSS contrast instead of fixing it), so its toggle moves from the pre-scan `ready` footer to the `scanned` footer, alongside Re-scan / Fix. - Normalize the side-panel (agent column) typography: strip the many small explicit sizes (text-xs / text-sm / text-[10px]/[11px]/[13px] and their `!` icon variants) so text renders at the app default (14px). Kept the large score display (text-2xl/4xl) and font weights. 161 tests, lint, and the dotcms-ui AOT build all green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… 400px The whole side-panel accordion scrolled as one block (headers and all). Make the accordion a flex column that fills the aside; the open scanner panel grows to take the remaining height and its CONTENT scrolls in place while the panel headers stay pinned. - Accordion → flex flex-col; scanner panel flex-1 min-h-0 (only while open), its content region overflow-y-auto via [pt]. - PrimeNG's collapse animation wraps content in a grid + <p-motion> + wrapper its [pt] API can't reach; a scoped ::ng-deep sets min-height:0 on them (and the grid row to minmax(0,1fr)) so the innermost content can bound + scroll. - Files panel: flex-none with content max-h-[400px] overflow-y-auto — it takes its natural height (typically short) and only scrolls once past 400px, so a long file list never steals the scanner's space. Verified live at tall + short viewports: scanner content scrolls in place with headers fixed; files caps at 400px. 161 tests, lint, AOT build all green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Dropping the [dt] border token (PrimeNG-defaults alignment) left no visible separator between the two accordion panels. Add a top border to the files panel. PrimeNG's preset sets border-width:0 on the panel, so the class needs `!` to win: border-t! border-surface-200!. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Finish the side-panel font normalization: strip the leftover small explicit sizes (text-[13px]/[12px]/[11px], text-xs/sm and their `!` icon variants) so they render at the app default (14px), keeping weights. - ai-ui dot-agent-message + dot-agent-thinking (the activity log's message / thinking rows, used only by the a11y agent) → message + sub text default. - a11y-diff files list (Files-changed panel body): empty state, file rows, +/- counts, back-to-preview button → default. Left untouched: the preview pane's address bars / mode badges and the diff VIEWER (right pane), per the side-panel-only scope. ai-ui 16 tests + dot-agents 161 tests, lint, and the dotcms-ui AOT build all green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…apsed The accordion-content scroll work left a regression: a collapsed panel's content (its grid row animated to 0 by PrimeNG) wasn't clipped, so it overflowed the 0-height cell and the whole run screen scrolled. - Clip .p-accordioncontent with overflow:hidden so a collapsed (0-row) panel never overflows; the open panel scrolls via its inner content instead. - Scope the shrinkable grid row (minmax(0,1fr)) AND the inner overflow-y:auto to the ACTIVE panel only — on a collapsed panel either would let content escape the 0-height cell. Moved the scroll off [pt] into the active-scoped ::ng-deep for both panels. Verified live: files collapsed → no page scroll; both open → no page scroll; scanner content still scrolls in place at a short viewport. 161 tests, lint, AOT build all green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The a11y-diff folder mixed conventions — a11y-diff.component uses an external templateUrl while a11y-diff-viewer inlined its ~35-line template. Move the viewer's template into a11y-diff-viewer.component.html so every non-trivial component in the folder is consistent. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…tcms-openapi-authoring-fixes
…ub.com/dotCMS/core into fmontes/dotcms-openapi-authoring-fixes
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
This PR delivers the accessibility-fix agent stack end to end. Tracks #36640.
Proposed Changes
This is a large, multi-feature branch (~81 commits, 117 files) delivering the accessibility-fix agent stack end to end: a REST proxy, the
page_createMCP tool plus authoring hardening, the reusable AI streaming UI kernel, and the new Accessibility Studio portlet. It also hardens several write endpoints and improves their OpenAPI docs.Backend — REST + Page
A11yAgentResource(/api/v1/agent/a11y): authenticates the backend user, mints a short-lived JWT, resolves the page identifier to a fully-qualified payload, then relays to the external agent service — plain JSON (/fix), SSE streaming (/fix/streamvia JerseyEventOutput),/stop, and/active-run. The agent base URL and service token come from thedotPageScanner-configApp secret, not from request input. Registered as a Swagger tag inDotRestApplication.TemplateResource.fillTemplate: 400"body required when drawed"(was a jsoup NPE) and 400"theme must be a folder identifier"whenthemedoesn't resolve to a folder (was an NPE onFolder.getHostId).UnrecognizedPropertyExceptionMapper(global@Provider, applies to all Jackson-deserialized forms): replaces the verbose Jackson message withUnrecognized field 'x'. Valid fields are: [...]. Full detail is still logged server-side.WorkflowResourcefire endpoint: advisory now suggestscontentHost/hostFolderwhen a strayhost/hostId/hostname/folderkey is sent.PageResourceloadJson/render/renderHTMLOnly: declare the existinghost_idas an explicit@QueryParam(backend users only) so a non-default-site render is discoverable in the spec, with docs stating the//host/uripath form is not supported. No HTTP behavior change —PageResourceHelperalready honored?host_id=off the request for backend users; this makes the param a first-class, documented one. It does change the Java method signatures (adds one arg), which is whyPageResourceTestcall sites were updated (see thetest(page):commit).getRenderSources(/_render-sources) already hadhost_idonmain.@Schemarequest-body descriptions onPageForm,TemplateForm,SiteForm,FireActionForm, andFolderResource.loadFolderByURI; new typed DTOsContentTypeFieldView,ContentTypeRequestViewreferenced fromContentTypeResource/FieldResource(replacingString.class/ContentTypeForm.classrequest schemas).openapi.yamlregenerated (+610 lines) to match the annotation changes.MCP server +
@dotcms/aiSDKpage_createtool +page-createlib: creates and publishes a page in one call. SplitsurlPathinto parent folder + leaf (creating the folder first, idempotently) to avoid the silent/indexURL-collapse trap; resolves anyHTMLPAGEbase-type content type (no longer hard-coded tohtmlpageasset); validates user-added required fields before firing; resolves the site to its identifier and sends it ascontentHost(fixes the root-pagehost is nullNPE).execute,search,upload_assets): correct field-var casing guidance (contentHost,cachettl),searchreframed as a curated allow-list with a guard againstTypeErroron missing spec paths,upload_assetsaccepts string booleans without thez.coerce "false" → truetrap and uploads empty files as-is, execute sandbox timeout default raised to 45s.@dotcms/aiadapter: binary responses (e.g./api/v2/assets,/dA) return a{ __dotcmsBinary, contentType, base64, byteLength }envelope; newformat-result/worker-harnesssandbox helpers; spec generation now keeps request/response schemas with context caps and includes/templates/{id}/working.ai-evalsupdated for the new SDK surface.Accessibility Studio portlet +
ai-ui@dotcms/portlets/dot-accessibility-studioportlet (routeaccessibility-studioinapp.routes.ts): pick a page, run a real axe-core scan feeding a score/severity widget, draw violation markers inside the preview iframe (phase-aware overlay), stream the agent's fixes live, surface an axe "incomplete" Needs-your-review section, and re-scan / publish / discard. SignalStore-based (accessibility-studio.store.ts) with amock-fix-reportfor the run view.@dotcms/ai-uilibrary: the agent-agnostic render kernel extracted from the studio —AgentMessageview-model +AgentMessagePresenter<T>seam and three components (dot-agent-message,dot-agent-now-doing,dot-agent-activity-log). The a11y studio'sA11yAgentPresenteris the first consumer.@dotcms/dotcms-models(AgentRunStep,AgentStreamEvent<T>,AgentRunStatus) and a generic SSE transportDotAgentRunServicein@dotcms/data-access.dot-page-scanner-reportUI updated to consume the raw axe-core scanner response.apps/dotcms-agents/ai-agentsapp and theagents-contract/@dotcms/agentic-toolslibs were removed/migrated; contract types are now inlined or served from@dotcms/ai.@ngneat/spectatorto@openng/spectatorto match the upstream rename.Docs
docs/plans/(a11y-agent phased plan, S0–S5 session notes, authoring skill rules, and the AI vision/PR-FAQ/SDK/competitive-landscape notes). No product-code impact.Checklist
ai-uicomponents,DotAgentRunService,page-create, and the@dotcms/aisandbox/adapter (incl. SSRF and binary-cap cases). Backend:PageResourceTestcall sites were updated for the newhost_idarity (compile fix), but no new backend test coverage was added — the write-endpoint 400 fixes,A11yAgentResource, and the explicithost_idparam are not yet asserted by an IT/Postman case. Reviewer should decide whether that coverage is required before merge (a Postman case for?host_id=onrender/jsonwould be the natural addition).Language.propertieskeys added here; the portlet consumesaccessibility.studio.*/page.scanner.a11y.*keys that already exist onmain.A11yAgentResourceresolves the agent URL and service token from an App secret, not from user input; it mints a short-lived JWT rather than forwarding the caller's credentials, and does not log the raw payload or secret.@dotcms/aiadapter validates user-supplied file URLs before fetching (rejects loopback / link-local169.254.0.0/16incl. cloud metadata / RFC 1918 / IPv6 unique-local) — SSRF mitigation — and caps binary response bodies at 25MB (checked againstContent-Lengthbefore buffering).Referer.Additional Info
Please review with the scope note above in mind: the theme/servlet/sourcemap/PageMode backend and the message-key additions are companion work already on
main, so this branch is best reviewed as the agent + tooling + UI layer on top of it. The highest-risk backend items to scrutinize are theTemplateResource.fillTemplateguards and the now-globalUnrecognizedPropertyExceptionMappermessage change (it affects every Jackson-deserialized write endpoint, not just the new ones).Verification done on this rebase:
dotcms-corecompiles clean; regeneratedopenapi.yamlis byte-identical to the committed one (CI consistency check passes); frontend suites pass —sdk-ai(60),mcp-server(26),ai-ui(14), accessibility-studio portlet (83),data-access/dot-agent-run(7);ai-evalstypechecks;sdk-aibuilds.Screenshots
Not included — this description was generated from the diff and cannot capture the Accessibility Studio UI. Please attach screenshots/recordings of the picker, live scan + marker overlay, and the agent fix stream before requesting review.
🤖 Generated with Claude Code