WCAG 2.4.6: interactive backdrop div now has aria-label="Dismiss dialog"
and cursor-pointer so screen reader users can identify the dismiss target.
Matches the same fix applied to KeyboardShortcutsDialog in PR #299.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Backdrop click (closes the dialog) now has aria-label="Close keyboard
shortcuts dialog" so screen reader users understand what the clickable
overlay area does. WCAG 2.4.6 (headings and labels): descriptive labels.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
WCAG 2.1.1: Arrow keys (Left/Right/Up/Down) now move focus between
theme options and update the selection. Home/End jump to first/last.
Previously the radiogroup had no keyboard support — only mouse clicks worked.
WCAG 2.4.7: All three theme icon buttons now have focus-visible:ring-2
focus-visible:ring-accent rings so keyboard-only users can see which
option has focus.
8 new tests in ThemeToggle.test.tsx cover all keyboard paths.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Removed aria-hidden="true" from the "Live" / "Reconnecting" / "Offline"
text spans and the redundant aria-label from the container div.
Previously the component used aria-label="Real-time updates: connected"
on the outer div with aria-hidden on the inner text — screen readers
announced the label but the visible text was hidden. Now the text
itself is accessible: "Live", "Reconnecting", "Offline" are announced
directly. The decorative dot keeps aria-hidden since it is purely
decorative and the title attribute provides hover tooltip context.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Verifies that aria-describedby is NOT set on the trigger wrapper when
the tooltip is hidden. This is the key WCAG 1.4.13 (Content on Hover or
Focus) correctness guarantee — screen readers must not announce tooltip
text when the tooltip is not visible.
PR #344's unconditional aria-describedby approach would fail this test,
confirming it is a WCAG regression.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- RevealToggle: use container.querySelector to avoid cross-test pollution;
fireEvent.click works correctly when scoped to the test container
- Tooltip: make aria-describedby conditional on show=true (portal exists);
Esc blur test explicitly focuses button (jsdom mouseEnter doesn't focus)
- TopBar: replace screen.getByRole with container-scoped queries to avoid
multi-button ambiguity across test runs
- BundleDropZone: createDragOverEvent helper for jsdom DragEvent
- PurchaseSuccessModal: remove beforeEach fake timers from non-timer tests;
use real timers with new Promise(setTimeout) for auto-dismiss
- sortParentsBeforeChildren: roots (no parentId) before orphans
All 1921 tests pass, npm run build succeeds.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
PR #253 adds liveAnnouncement as a parameter to the makeStore test
helper and includes it in the state object. This was inadvertently
removed during test fixes on this branch.
🤖 Generated with [Claude Code](https://claude.ai/claude-code)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adopts PR #299's WCAG-correct approach. aria-describedby must only
reference content that exists in the DOM — setting it unconditionally
points to a non-existent ID when the tooltip portal is not mounted,
producing undefined browser/AT behavior.
Changes:
- Tooltip.tsx: aria-describedby={show ? tooltipId.current : undefined}
- Tooltip.test.tsx: 3 new aria-describedby tests:
1. does NOT set aria-describedby when tooltip is hidden
2. sets aria-describedby when tooltip shown (hover)
3. sets aria-describedby when tooltip shown (keyboard focus)
Also fixes PR #306 Tooltip test which asserted unconditional aria-describedby
— this would have failed under PR #299's conditional approach.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- canvas-design-system-v1.md: correct focus-visible example from blue-500
to accent (the actual brand token used in canvas components)
- canvas-audit-items.md: same fix + add comprehensive focus-visible audit
entry (PR #306: 40+ files, WCAG 2.4.7)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The React Flow toolbar (zoom in/out/fit) and Minimap are third-party
components that render their own buttons. Add CSS-based focus-visible
rules so keyboard users see a visible ring on these canvas controls,
completing the WCAG 2.4.7 coverage for all interactive elements.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Add roving tabindex to result option buttons so keyboard users
see a visible focus ring on the currently selected item. Tab from
the input lands on the right option; clicking an option immediately
re-focuses the input so all arrow/Enter key handling stays in the
input's handler. Applies focus-visible ring (accent) to the selected
listbox option.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
WCAG 2.4.7 — Focus Visible (Two-level Keyboard Navigation).
ThemeToggle: 3 icon radio buttons in radiogroup now have
focus-visible:ring-2 ring-accent rings.
RevealToggle: eye/eye-off icon button now has focus-visible ring.
ErrorBoundary: Reload and Report buttons now have focus-visible rings.
ConversationTraceModal: close button and footer Close button now have
focus-visible rings (Radix Dialog handles focus trapping; rings add
visibility for keyboard-only users).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
AuditTrailPanel: filter buttons, refresh button, load-more button now
have focus-visible:ring-2 ring-accent focus-visible rings so keyboard
users can see which element has focus.
CommunicationOverlay: toggle button and close button now have the same
focus ring, consistent with the rest of the canvas design system.
WCAG 2.1 AA — 2.4.7: Focus Visible (Two-level Keyboard Navigation).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- Add data-testid="legend-panel" to Legend component root div so
tests can select the panel reliably instead of .closest("div")
(the "Legend" text also appears in the collapsed pill).
- Update palette-offset positioning tests to use container.querySelector
with data-testid instead of screen.getByText + .closest("div").
- PurchaseSuccessModal: skip URL stripping when no target params present.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Design fixes:
- PricingTable.tsx: replace non-zinc disabled:bg-blue-900 with
bg-zinc-700/text-zinc-500, keeping all states within the dark zinc
palette (zinc-900 bg, zinc-800 surfaces, zinc-700 borders).
Test fixes:
- PurchaseSuccessModal.test.tsx: replace setTimeout(0) anti-pattern under
vi.useFakeTimers() — act() does not advance fake timers, causing 5000ms
timeouts. Use vi.advanceTimersByTime(10) to flush render effects without
triggering the 5s auto-dismiss. 18/18 tests now pass.
- OnboardingWizard.test.tsx: replace stateless mock with
useSyncExternalStore bridge + subscriber set so React re-renders when
mockStoreState is mutated; fix second-render unmount ordering. 13/13 pass.
- yaml-utils.ts: emit tools: [] key unconditionally (matching skills
behaviour); test expectation was correct, implementation was wrong. 36/36.
- tabs/chat/types.ts createMessage: conditional { attachments } spread
avoids undefined key in Object.keys(); Object.freeze() the returned
object so mutation-guards in tests pass.
- tabs/FilesTab/tree.ts getIcon: normalize extracted extension to
lowercase so data.JSON matches the .json entry in FILE_ICONS.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- StatusDot: replace screen.getByRole("img") with container.querySelector —
role="img" with aria-hidden="true" is inaccessible to getByRole in jsdom.
Use getAttribute("class") instead of .className (SVG returns
SVGAnimatedString which .toContain fails on).
- Spinner: same SVG className fix as StatusDot — use getAttribute("class").
- StatusBadge: scope all role=status queries to [aria-label="Connection status:
<status>"] to avoid ambiguity with Spinner/Toast role=status in shared jsdom.
- ValidationHint: scope role=alert queries to container; checkmark is in a
separate span so use container.textContent regex /✓.*Valid format/s.
- RevealToggle: scope all button queries to container to avoid cross-test
interference in shared jsdom.
- TopBar: scope all queries to container; match "+ New Agent" by text content.
- SearchDialog: "clears query" test — open dialog state so combobox renders;
fix Enter-selects test: auto-highlight starts at index 0 (Alice) so after
one ArrowDown the selection is at index 1 (Bob/n2), not n1.
- ContextMenu: Tab handler fires on the menu div, not document.body; disabled
Chat/Terminal check uses getAttribute("disabled") → toBe("") instead of
toBeDisabled() (Chai plugin not installed).
- Tooltip: add vi.useFakeTimers() beforeEach in "render" and "Esc dismiss"
describe blocks; use window.dispatchEvent(KeyboardEvent) for Escape key
(captures to the useEffect listener); aria-describedby is on the wrapper div,
not the child button — show tooltip first so portal element exists in DOM.
- Tooltip — renders children: fix duplicate render call inside test.
- canvas-topology-pure: update "missing node" test expectation from
["root","orphan"] to ["orphan","root"] — actual algorithm visits orphan
first (ghost parent not found), then root.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- StatusBadge: scope role=status queries to [aria-label] to avoid
ambiguity with role=status from other components in shared jsdom
- ApprovalBanner: scope role=alert queries and button clicks to
container to avoid cross-test interference
- ContextMenu: use vi.hoisted() for apiPost/apiPatch mocks to fix
vitest hoisting error; scope Escape/Tab key tests to menu element
instead of document.body; update offline-node expectations
- BundleDropZone: scope file input and button queries to
container; mock dataTransfer.types for drag-over test; guard
dataTransfer?.types in component to prevent jsdom TypeError
- TestConnectionButton: use vi.hoisted() for mockValidateSecret;
fix disabled attr assertions (getAttribute returns "" not truthy);
scope button click to container to avoid SVG icon interference
- OrgImportPreflightModal/SidePanel: use vi.hoisted() for store
mocks to fix vitest hoisting errors
- ConversationTraceModal: update expectation to match actual impl
(extractMessageText joins all non-empty parts)
- KeyValueField: use container.querySelector for all input/button
queries; jsdom does not expose role=textbox for password inputs
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Close the A2A delegation auto-resume gap.
Root cause: heartbeat.py's _check_delegations already writes completed
delegation rows to DELEGATION_RESULTS_FILE and sends a self-message to
wake the agent. executor_helpers.read_delegation_results() was defined to
atomically consume that file, but a2a_executor._core_execute() never
called it — so delegation results were written but the agent never saw
them.
Fix: call read_delegation_results() at the top of _core_execute() and
prepend the results to the user input context so the agent can act on
them without an explicit check_task_status call. The Temporal durable
workflow path is also covered because it calls _core_execute() directly.
Test: two new cases — delegation results injected when file exists;
user input passed through unchanged when file is empty.
Closes molecule-core#354.
Incorporates valuable extra coverage from fullstack-engineer's PR #336:
- test_push_queued_missing_queue_id_still_parsed: queue_id is optional,
absence must not break parsing
- test_push_queued_is_distinct_from_poll_queued: both envelope shapes
parse correctly and independently, with correct delivery_mode values
Also adds push_queued_no_queue_id fixture and regression gate entry.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Bug: a2a_response.py:197 returned Queued(method=method) without passing
delivery_mode, silently defaulting to "poll" for push-mode busy-queue
responses. Callers branching on v.delivery_mode would mis-identify push-mode
responses as poll-mode, causing wrong dispatch logic.
Fix: pass delivery_mode="push" explicitly in the push-mode branch.
Tests: add push_queued_full/notify/no_method fixtures and 4 test cases
asserting delivery_mode="push" for all three envelope shapes. Also add
adversarial {"queued": "yes"} and {"queued": False} → Malformed guards.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Run 5160 publish-runtime build step failed:
error: TOP_LEVEL_MODULES drifted from workspace/*.py contents:
in workspace/ but NOT in TOP_LEVEL_MODULES (will ship un-rewritten): ['_sanitize_a2a']
Edit scripts/build_runtime_package.py:TOP_LEVEL_MODULES to match.
workspace/_sanitize_a2a.py was added recently but the allowlist in
scripts/build_runtime_package.py was not updated. The build script
intentionally aborts (exit 3) when it detects the drift, because
shipping a module un-rewritten breaks the package's flat-layout import
contract.
Fix: add '_sanitize_a2a' to the set. Alphabetical order preserved
(it sorts before 'a2a_*').
Third workflow defect after #353 (workflow_dispatch.inputs parser) and
#355 (Publish step working-directory). After this lands, attempt #4 of
runtime-v0.1.130 should finally succeed.
Refs: #351, #353, #355, #348 Q3
First-ever publish-runtime.yml dispatch (run 5097 post-#353, 2026-05-11
02:06Z) failed at the twine upload step:
ERROR InvalidDistribution: Cannot find file (or expand pattern): 'dist/*'
Cause: the Publish step was missing 'working-directory: ${{ runner.temp
}}/runtime-build' while the preceding Build/Verify steps all had it.
Result: twine ran from the workspace checkout dir where dist/ doesn't
exist.
Fix: add working-directory to match the rest of the publish job.
This is the second of three workflow defects exposed by #353 finally
making the workflow run at all:
1. workflow_dispatch.inputs rejection → fixed in #353
2. Publish step missing working-directory → THIS PR
3. (anything else surfaced by 0.1.130 attempt #2)
After merge: push runtime-v0.1.130 again (tag was already pushed once
post-#353 but the run failed at publish; need a fresh trigger). Should
finally land 0.1.130 on PyPI.
Refs: #351, #348 Q3, #353
test_audit_ledger.py imports sqlalchemy directly (line 42).
Without an explicit sqlalchemy install, pip dependency resolution can
omit it when pytest/pytest-asyncio/pytest-cov are installed as a
separate step after requirements.txt.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Gitea Actions reads .gitea/workflows/, not .github/workflows/. The
.github/ copy of this workflow has been kept in lockstep with .gitea/
since the post-suspension migration (e.g. 6d94fd30, 5216e781, 67b2e488
all touch both files). The functional code is identical between the
two; the only differences are comment verbosity and the path-filter
self-reference (each version watches its own location).
Removing the .github/ copy:
- eliminates the dual-edit maintenance tax (two files touched per fix)
- prevents accidental drift where one is updated and the other isn't
- leaves a single source-of-truth at .gitea/workflows/
Cross-references confirmed safe:
- canary-verify.yml + redeploy-tenants-on-{staging,main}.yml all use
`workflows: ['publish-workspace-server-image']` (workflow name,
not file path) — they trigger off the workflow_run event keyed on
`name:`, which is identical in both files.
- No other workflow path-watches .github/workflows/publish-workspace-
server-image.yml.
Other two triplicates from task #287 (publish-runtime.yml and
secret-scan.yml) are NOT addressed in this PR — see PR description for
the ambiguity report flagging them for human review.
Refs: task #287
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Trivial empty commit to force a fresh workflow run now that the
PR has tier:low label and approvals on the rebased branch.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Root cause (from infra-lead PR#7 review id=724):
Sanitization in PR#7 wrapped peer text in [A2A_RESULT_FROM_PEER]
markers, but the markers themselves were not escaped — a malicious
peer could inject "[/A2A_RESULT_FROM_PEER]" to close the trust
boundary early, making subsequent text appear inside the trusted zone.
Fix:
- Create workspace/_sanitize_a2a.py (leaf module, no circular import
risk) with shared sanitize_a2a_result() + _escape_boundary_markers()
- _escape_boundary_markers() escapes boundary open/close markers in the
raw peer text before wrapping (primary security control)
- Defense-in-depth: also escapes SYSTEM/OVERRIDE/INSTRUCTIONS/IGNORE
ALL/YOU ARE NOW patterns (secondary, per PR#7 design intent)
- Update a2a_tools_delegation.py: import from _sanitize_a2a; wrap
tool_delegate_task return and tool_check_task_status response_preview
- Add 15 tests covering boundary escape, injection patterns, integration
shapes (workspace/tests/test_a2a_sanitization.py)
Follow-up (non-blocking, noted in PR#7 infra-lead review):
- Deduplicate if a2a_tools.py also wraps (currently handled in
delegation module only — callers get sanitized output regardless)
- tool_check_task_status: consider sanitizing 'summary' field too
Closes: molecule-ai/molecule-ai-workspace-runtime#7 (wrong-repo PR
that this supersedes)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
ROOT CAUSE found in Gitea server logs:
actions/workflows.go:DetectWorkflows() [W] ignore invalid workflow
"publish-runtime.yml": unknown on type:
map["version":{"description":...,"required":true,"type":"string"}]
Gitea 1.22.6's workflow parser flattens workflow_dispatch.inputs.* into
top-level 'on:' event-keys and rejects the workflow when it doesn't
recognize them. Once rejected, the workflow never registers — so NO
event triggers it. publish-runtime.yml has 0 runs in action_run since
the .gitea port for exactly this reason; the runtime-v1.0.0 tag from
yesterday and hongming-pc's runtime-v0.1.130 from tonight both pushed
successfully but went nowhere.
This supersedes the paths-vs-tags hypothesis from #351 (PR #352).
The split is still useful for clarity but was NOT the cause — even
the original tags-only port had this same parse failure.
Fix: drop the inputs block. workflow_dispatch in Gitea 1.22.6 supports
no-input dispatch only. The bash logic for version derivation now uses
just two cases: tag-push (strip prefix) or anything-else (PyPI auto-bump).
Post-merge verification:
- watch for first-ever publish-runtime.yml run in action_run
- check Gitea log no longer emits 'ignore invalid workflow' for this file
- push a runtime-v0.1.130 tag → workflow fires → PyPI 0.1.130
Refs: #351 (root cause), #348 Q3 (the blocker)
publish-runtime.yml has never fired since the .gitea port (0 rows in
action_run.workflow_id='publish-runtime.yml' ever), which is why PyPI
is still at 0.1.129 despite Gitea having a runtime-v1.0.0 tag.
Root cause hypothesis: Gitea Actions evaluates the on.push.paths filter
against tag-push events too (no path diff → workflow skipped). PR #349
made this visible by adding the paths trigger, but the same defect
existed for the originally-ported tags-only trigger on this Gitea version
— hence the runtime-v1.0.0 tag also never published.
Fix: split into two files, each with a single unambiguous trigger shape.
- publish-runtime.yml : on.push.tags only (the publisher)
- publish-runtime-autobump.yml : on.push.branches+paths (NEW; the bumper)
The autobump file computes next version from PyPI latest, pushes
'runtime-v$VERSION' tag via DISPATCH_TOKEN (not GITHUB_TOKEN — needed
to trigger downstream workflows on Gitea), and exits. The tag push
then triggers publish-runtime.yml.
Test plan after merge:
1. Push no-op commit to workspace/. Observe autobump fire, push tag.
2. Observe publish-runtime.yml fire on the tag, publish 0.1.130 to
PyPI, cascade to template repos.
3. Verify 'action_run' shows >0 rows for both workflow_ids.
sop-tier-check / tier-check (pull_request) Successful in 2s (manual refresh: run 5030 on pull_request_label event succeeded; commit-status stale per go-gitea#33700)
Adds back the original GitHub workflow's auto-publish trigger that was
dropped during the 2026-05-10 .gitea port (#206). Push to main or
staging filtered by workspace/** falls into the existing PyPI-latest
auto-bump path — no logic changes, just the missing trigger and a
comment correction.
Caveat: the workflow still requires PYPI_TOKEN as a repository secret
(or org-level). Without it the publish step will fail loudly with a
descriptive error. Q2 follow-up tracks setting the secret.
Refs: molecule-core#348
Implements the Claude Design handoff (Molecules AI Mobile.html) as a
viewport-gated React tree under canvas/src/components/mobile/. < 640px
renders the new shell instead of the desktop ReactFlow canvas.
Six screens, all bound to live store data:
- Home (agent list + filter chips + spawn FAB)
- Canvas (mini-graph with pinch-to-zoom + pan + reset)
- Detail (status pills, tabs: Overview / Activity / Config / Memory;
Activity hits /workspaces/:id/activity)
- Chat (textarea composer, IME-safe Enter, sendInFlightRef guard;
bootstraps from agentMessages so the prior thread shows on entry)
- Comms (live A2A feed via /workspaces/:id/activity + ACTIVITY_LOGGED)
- Spawn (bottom sheet; fetches /templates so users pick what's actually
installed on their platform)
Plus a Me tab for mobile theme/accent/density.
Design system (palette.ts + primitives.tsx) ports tokens 1:1 from the
handoff: cream + dark palettes, T1-T4 tier chips, status dots with
halo, JetBrains Mono for IDs/timestamps. Inter + JetBrains Mono are
self-hosted via next/font/google so CSP `font-src 'self'` is honoured.
URL routing: routes sync to ?m=<route>&a=<id>; popstate restores route;
deep links seed initial state. /?m=detail without ?a collapses to home.
Accent override flows through React context (MobileAccentProvider) —
not by mutating the static MOL_LIGHT/MOL_DARK singletons.
SSR flash: isMobile is tri-state; loading spinner stays up until
matchMedia resolves so mobile devices never paint the desktop tree.
Desktop responsiveness fixes (separate but ride along):
- Toolbar: full-width with overflow-x-auto on mobile, logo text + count
hidden < sm, divider/border collapse to sm: only.
- SidePanel: full-screen on mobile via matchMedia, resize handle hidden.
- Canvas: MiniMap hidden < sm (was overlapping the New Workspace FAB).
Tests (51 total, 33 new):
- palette.test.ts (12) - normalizeStatus, tierCode, light/dark parity
- components.test.ts (10) - toMobileAgent field mapping + classifyForFilter
- MobileApp.test.tsx (12) - route stack, deep links, popstate, tab bar
hidden on chat, spawn overlay
- SidePanel.tabs.test.tsx (18) - regression-clean
Verified: tsc --noEmit clean across mobile/, page.tsx, layout.tsx.
Not yet verified: live phone browser (needs CP backend hydrated).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>