Skip to content

feat(a11y): Accessibility Studio portlet + AI agent platform (@dotcms/ai, MCP server, agent UI) - #36641

Draft
fmontes wants to merge 139 commits into
mainfrom
fmontes/dotcms-openapi-authoring-fixes
Draft

feat(a11y): Accessibility Studio portlet + AI agent platform (@dotcms/ai, MCP server, agent UI)#36641
fmontes wants to merge 139 commits into
mainfrom
fmontes/dotcms-openapi-authoring-fixes

Conversation

@fmontes

@fmontes fmontes commented Jul 20, 2026

Copy link
Copy Markdown
Member

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_create MCP 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.

Scope note for reviewers: the theme/servlet backend pieces (CSSPreProcessServlet inline SCSS sourcemap + EDIT-permission gate, PageMode for styles.css/template.vtl, and the /_render-sources files[]/extension change) already landed on main via the companion backend work (e.g. #36264 and the fmontes/a11y-agent-backend branch). They are not in this diff. This branch only consumes /_render-sources on the agent side. The same applies to the accessibility.studio.* and page.scanner.a11y.* message keys — those already exist on main; this branch adds no Language.properties entries.

Backend — REST + Page

  • New 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/stream via Jersey EventOutput), /stop, and /active-run. The agent base URL and service token come from the dotPageScanner-config App secret, not from request input. Registered as a Swagger tag in DotRestApplication.
  • Write-endpoint 500 → clean 400 hardening:
    • TemplateResource.fillTemplate: 400 "body required when drawed" (was a jsoup NPE) and 400 "theme must be a folder identifier" when theme doesn't resolve to a folder (was an NPE on Folder.getHostId).
    • UnrecognizedPropertyExceptionMapper (global @Provider, applies to all Jackson-deserialized forms): replaces the verbose Jackson message with Unrecognized field 'x'. Valid fields are: [...]. Full detail is still logged server-side.
    • WorkflowResource fire endpoint: advisory now suggests contentHost/hostFolder when a stray host/hostId/hostname/folder key is sent.
  • PageResource loadJson / render / renderHTMLOnly: declare the existing host_id as an explicit @QueryParam (backend users only) so a non-default-site render is discoverable in the spec, with docs stating the //host/uri path form is not supported. No HTTP behavior changePageResourceHelper already 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 why PageResourceTest call sites were updated (see the test(page): commit). getRenderSources (/_render-sources) already had host_id on main.
  • Typed @Schema request-body descriptions on PageForm, TemplateForm, SiteForm, FireActionForm, and FolderResource.loadFolderByURI; new typed DTOs ContentTypeFieldView, ContentTypeRequestView referenced from ContentTypeResource/FieldResource (replacing String.class/ContentTypeForm.class request schemas).
  • openapi.yaml regenerated (+610 lines) to match the annotation changes.

MCP server + @dotcms/ai SDK

  • New page_create tool + page-create lib: creates and publishes a page in one call. Splits urlPath into parent folder + leaf (creating the folder first, idempotently) to avoid the silent /index URL-collapse trap; resolves any HTMLPAGE base-type content type (no longer hard-coded to htmlpageasset); validates user-added required fields before firing; resolves the site to its identifier and sends it as contentHost (fixes the root-page host is null NPE).
  • Authoring-tool hardening (execute, search, upload_assets): correct field-var casing guidance (contentHost, cachettl), search reframed as a curated allow-list with a guard against TypeError on missing spec paths, upload_assets accepts string booleans without the z.coerce "false" → true trap and uploads empty files as-is, execute sandbox timeout default raised to 45s.
  • @dotcms/ai adapter: binary responses (e.g. /api/v2/assets, /dA) return a { __dotcmsBinary, contentType, base64, byteLength } envelope; new format-result/worker-harness sandbox helpers; spec generation now keeps request/response schemas with context caps and includes /templates/{id}/working.
  • ai-evals updated for the new SDK surface.

Accessibility Studio portlet + ai-ui

  • New @dotcms/portlets/dot-accessibility-studio portlet (route accessibility-studio in app.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 a mock-fix-report for the run view.
  • New @dotcms/ai-ui library: the agent-agnostic render kernel extracted from the studio — AgentMessage view-model + AgentMessagePresenter<T> seam and three components (dot-agent-message, dot-agent-now-doing, dot-agent-activity-log). The a11y studio's A11yAgentPresenter is the first consumer.
  • Supporting wire contract in @dotcms/dotcms-models (AgentRunStep, AgentStreamEvent<T>, AgentRunStatus) and a generic SSE transport DotAgentRunService in @dotcms/data-access.
  • Existing dot-page-scanner-report UI updated to consume the raw axe-core scanner response.
  • The old apps/dotcms-agents/ai-agents app and the agents-contract / @dotcms/agentic-tools libs were removed/migrated; contract types are now inlined or served from @dotcms/ai.
  • Branch-owned specs migrated from @ngneat/spectator to @openng/spectator to match the upstream rename.

Docs

  • Session/plan docs under 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

  • Tests — Frontend: extensive new Jest specs for the portlet store/services/presenter, ai-ui components, DotAgentRunService, page-create, and the @dotcms/ai sandbox/adapter (incl. SSRF and binary-cap cases). Backend: PageResourceTest call sites were updated for the new host_id arity (compile fix), but no new backend test coverage was added — the write-endpoint 400 fixes, A11yAgentResource, and the explicit host_id param 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= on render/json would be the natural addition).
  • Translations — No new Language.properties keys added here; the portlet consumes accessibility.studio.* / page.scanner.a11y.* keys that already exist on main.
  • Security Implications Contemplated:
    • A11yAgentResource resolves 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/ai adapter validates user-supplied file URLs before fetching (rejects loopback / link-local 169.254.0.0/16 incl. cloud metadata / RFC 1918 / IPv6 unique-local) — SSRF mitigation — and caps binary response bodies at 25MB (checked against Content-Length before buffering).
    • Write-endpoint failures now return clean 400s; the unrecognized-field mapper lists valid field names to the client but keeps the verbose Jackson detail server-side only.
    • (Companion backend PR, not this diff) The CSS sourcemap output is gated behind EDIT permission and avoids logging the raw 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 the TemplateResource.fillTemplate guards and the now-global UnrecognizedPropertyExceptionMapper message change (it affects every Jackson-deserialized write endpoint, not just the new ones).

Verification done on this rebase: dotcms-core compiles clean; regenerated openapi.yaml is 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-evals typechecks; sdk-ai builds.

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

fmontes and others added 30 commits July 20, 2026 11:53
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>
fmontes and others added 29 commits August 3, 2026 12:00
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>
@github-actions github-actions Bot removed the Area : Documentation PR changes documentation files label Aug 5, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Area : Backend PR changes Java/Maven backend code Area : Frontend PR changes Angular/TypeScript frontend code Area : SDK PR changes SDK libraries

Projects

Status: No status

Development

Successfully merging this pull request may close these issues.

Accessibility Studio portlet + AI agent platform

1 participant