Adds a public /pricing route the apex + tenant canvas can both serve.
Three-tier plan cards (Free, Starter, Pro) with per-plan CTA buttons
that dispatch correctly regardless of the user's state:
Free → redirect to signup
Anonymous + paid → redirect to signup (Stripe opens post-auth)
Authed + paid → POST /cp/billing/checkout, redirect to Stripe URL
No tenant slug → inline error ("pick an org first")
Network failures → surfaced in an ARIA alert banner
Files:
- src/lib/billing.ts — plan metadata + startCheckout + openBillingPortal
wrappers over /cp/billing/{checkout,portal}
- src/components/PricingTable.tsx — client component, lazy session
probe on first CTA click (no probe for anonymous browsers)
- src/app/pricing/page.tsx — server-rendered shell with SEO metadata,
links to legal pages in the footer
- Tests: 10 billing helper tests + 9 PricingTable tests (17 total,
additional ones cover the plan-list canonical order)
Design notes:
- The pricing data (features + prices) is a static const in billing.ts,
not fetched from the API. Changing prices requires a deploy — which
we'd need to do anyway for tier definition changes.
- PLAN_ID 'starter' is flagged highlighted=true so the middle card gets
the 'Most popular' visual treatment. One source of truth; test locks it.
- Session probe is lazy (first CTA click, not mount) so anonymous
visitors don't generate a /cp/auth/me request just to read the page.
AuthGate interaction:
- On apex (no tenant slug), AuthGate passthrough — /pricing renders freely
- On tenant subdomain, AuthGate still bounces anonymous users to login
before reaching /pricing — this is the correct UX for the "I'm already
logged in and want to upgrade my own org" flow
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Adds a GDPR/ePrivacy-compliant cookie banner to the canvas root layout.
Privacy-preserving default: no optional cookies are considered accepted
until the user clicks "Accept all". Clicking "Necessary only" or
dismissing records "rejected" and the banner does not re-appear until
the cookie-policy version bumps.
- New CookieConsent component wired into src/app/layout.tsx so it
renders on every canvas route
- Persists decision to localStorage as {decision, decidedAt, version}
- Versioned schema: bumping CURRENT_VERSION re-prompts every user
- Exports hasConsent() helper for feature code that needs to gate
analytics / functional cookies on user choice
- ARIA: role=dialog + aria-labelledby/aria-describedby so screen
readers announce it as a dialog
- Same storage key + schema as the control-plane legal-page banner
(see molecule-controlplane PR #XX) so a user who accepts on one
surface does not re-see the banner on the other
Tests: 12 Vitest cases covering first-visit render, accept/reject
persistence, version re-prompt, invalid-JSON recovery, privacy link
attrs, ARIA markup, and the hasConsent helper under every state.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Addresses the three release-blocking WCAG violations from the UX audit
(3rd consecutive cycle) and the new ChatTab ARIA gap from Audit #2.
Changes:
- Toaster: split into polite (success/info) + assertive (error) live
regions, both always in DOM so screen readers register them before
any toast fires. Adds x dismiss button on every toast. Errors no
longer auto-expire after 4s — persist until explicitly dismissed.
- ConfirmDialog: on open, requestAnimationFrame focuses the first
button inside the dialog. Tab/Shift-Tab is now trapped inside the
dialog while open. Added role="dialog" aria-modal="true" and
aria-labelledby pointing to the title h3.
- WorkspaceNode: outer div gains role="button", tabIndex={0},
aria-label, aria-pressed, and onKeyDown (Enter/Space => selectNode,
ContextMenu key => openContextMenu). Keyboard-only users can now
reach and activate workspace nodes.
- ChatTab sub-tab bar: role="tablist" on wrapper, role="tab" +
aria-selected + aria-controls on each button, matching
role="tabpanel" + id on each panel div. Textarea gets
aria-label="Message to agent".
453/453 Vitest tests pass. Production build clean (Next.js 15).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
New nodes were placed at (0,0) or close to it, causing them to spawn
behind the toolbar/palette chrome and require manual panning to find.
Add GRID_ORIGIN_X/Y = 100 offset so the first node lands in clear canvas
space, and update the position assertion in the unit test accordingly.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Wraps the canvas root so every tenant-subdomain request checks for a
valid session and bounces to app.moleculesai.app/cp/auth/login with a
return_to pointing back at the current URL. Local dev + vercel preview
URLs + apex pass through unchanged.
Files:
- canvas/src/lib/auth.ts: fetchSession() probes /cp/auth/me
(credentials:include for cross-origin cookie); returns Session on 200,
null on 401 (anonymous, no throw), throws on 5xx so transient
outages don't leak the UI.
- canvas/src/lib/auth.ts: redirectToLogin() builds the cp login URL
with window.location.href as return_to; CP's isSafeReturnTo check
rejects cross-domain bounces.
- canvas/src/components/AuthGate.tsx: client component wrapping
children. State machine: loading → authenticated | anonymous. In
non-SaaS mode (no tenant slug) skips the gate entirely.
- canvas/src/app/layout.tsx: wraps the root body in <AuthGate>.
Tests: +6 auth.ts (200 / 401 null / 5xx throw / credentials:include /
redirectToLogin href + signup variant). Full suite 453 green (was 447).
Pairs with molecule-controlplane PR #16 (return_to cookie handshake
on the cp side).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Canvas will be served at <slug>.moleculesai.app (Vercel). API calls go
cross-origin to https://app.moleculesai.app. This commit wires the
client side:
- canvas/src/lib/tenant.ts: getTenantSlug() derives the slug from
window.location.hostname, case-insensitive, matching the control
plane's reservedSubdomains list (app/www/api/admin/…). Server-side
+ localhost + vercel preview URLs + apex all return "" so local dev
keeps working.
- canvas/src/lib/api.ts: adds X-Molecule-Org-Slug header + sets
credentials:"include" on every fetch. The control plane's CORS
middleware allows the origin + credentials; the session cookie has
Domain=.moleculesai.app so the browser ships it.
- canvas/src/lib/api/secrets.ts: same treatment (secrets API uses its
own fetch helper — shared slug+credentials logic applied).
Tests: +6 (tenant.test.ts covers slug / reserved / case / non-SaaS /
preview URL / apex). Full canvas suite 447/447 green.
Not in this PR:
- WS URL derivation for terminal/socket.ts (separate follow-up; WS
needs its own slug-aware URL and the canvas terminal isn't used in
SaaS launch day-one).
- Next.js rewrites (decided against; cross-origin with credentials
is cleaner than path-level rewrites for session cookies).
Deploys to Vercel once merged — no manual config needed (env already set).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Adds Z as a keyboard equivalent for the existing double-click zoom-to-team
gesture (WCAG 2.1.1). When a team node is selected, pressing Z dispatches
molecule:zoom-to-team, which fitBounds to the parent and all children.
Input elements are guarded so Z still types normally in text fields.
Adds a 6th help panel entry documenting the Dbl-click / Z gesture.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
globals.css: append @media (prefers-reduced-motion: reduce) block that zeroes
animation/transition durations, disables .animate-in/.slide-in-from-* entry
animations (Toaster, ApprovalBanner, SidePanel slide), strips dashdraw and
node-appear keyframes from React Flow elements.
Components: replace all bare animate-pulse (13 occurrences across WorkspaceNode,
StatusDot, Toolbar, SidePanel, Legend, SearchDialog, TerminalTab, TemplatePalette)
with motion-safe:animate-pulse so status indicator pulsing stops for users with
vestibular disorders. Replace 3 animate-bounce occurrences in ChatTab typing
indicator with motion-safe:animate-bounce.
Tests: new canvas/src/__tests__/reduced-motion.test.ts (12 tests) verifies the
@media block is present in globals.css and that every component file uses the
motion-safe: variant rather than bare animation classes.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Gap 1 — WS_URL now derives from NEXT_PUBLIC_PLATFORM_URL when
NEXT_PUBLIC_WS_URL is not set (http→ws, appends /ws; https→wss).
Operators need only one env var. NEXT_PUBLIC_WS_URL remains an explicit
override escape hatch.
Gap 2 — Add canvas/.env.example documenting NEXT_PUBLIC_PLATFORM_URL
(required) and NEXT_PUBLIC_WS_URL (optional override, commented out).
Gap 3 — Toolbar fires showToast("Live updates restored", "success")
when wsStatus transitions connecting→connected. mountedRef (set after
2 s) suppresses the toast on the very first page-load connection so
only genuine reconnects notify the user.
Gap 4 — New canvas/src/store/__tests__/socket.url.test.ts (6 tests):
· fallback to ws://localhost:8080/ws when no env set
· http→ws derivation from NEXT_PUBLIC_PLATFORM_URL
· https→wss derivation
· NEXT_PUBLIC_WS_URL override takes precedence
· api.ts PLATFORM_URL fallback
· api.ts reads NEXT_PUBLIC_PLATFORM_URL
375/375 tests passing, production build clean.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds a live/reconnecting/offline pill to the Toolbar so users can see
at a glance whether the canvas is receiving real-time updates.
Changes:
- canvas/src/store/canvas.ts: add wsStatus ('connected'|'connecting'|
'disconnected') field + setWsStatus action to CanvasState (initial:
'connecting')
- canvas/src/store/socket.ts: wire setWsStatus into ReconnectingSocket —
'connecting' on connect() call, 'connected' in onopen, 'connecting'
in onclose (will reconnect), 'disconnected' in disconnect()
- canvas/src/components/Toolbar.tsx: subscribe to wsStatus; render
WsStatusPill (green "Live" / amber pulsing "Reconnecting" / red
"Offline") after the workspace count section
- canvas/src/store/__tests__/socket.test.ts: add setWsStatus: vi.fn()
to the canvas store mock (global factory, beforeEach reset, and the
mid-test override in the onmessage test)
369/369 canvas tests passing, production build clean.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Replace raw Parent Workspace ID text input with a <select> populated
from GET /workspaces (T{tier} · {name} format, graceful fallback on
fetch error). Raise all interactive button text from text-[8px]/[9px]
to text-[11px] across SkillsTab, ScheduleTab, secrets-section,
ActivityTab, SidePanel, ChatTab; non-interactive labels/badges to
text-[10px]. Adds 7 CreateWorkspaceDialog unit tests (372/372 passing).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Wrap CanvasInner return in React Fragment to host skip-nav link as sibling of <main>
- Add <a href="#canvas-main"> skip link (sr-only, revealed on focus) before <main>
- Add id="canvas-main" to <main> element
- Add aria-label="Molecule AI workspace canvas" to ReactFlow wrapper
- Add Canvas.a11y.test.tsx: 4 jsdom tests covering all three a11y landmarks
369/369 tests pass; next build clean.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
UX Audit Run 6 critical finding: Legend panel and workspace node cards used 8px and 9px
text (6–7pt), which is physically unreadable and fails WCAG minimum guidelines.
- Legend.tsx: raise all text-[8px]/[9px]/[10px] → text-[11px] across every sub-component
(StatusItem labels, TierItem badge+label, CommItem icon+label, section headers)
- WorkspaceNode.tsx: raise text-[8px]/[9px] → text-[10px] for all readable labels in
the main card (status text, skill badges, task/error banners, tier badge, sub count,
Team Members header) and TeamMemberChip primary name/role text
Compact 7px elements inside TeamMemberChip (tier/sub badges, status micropills) retained
to preserve dense canvas layout — only human-readable labels were upgraded.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- computeAutoLayout() BFS tree layout seeds from anchored nodes; assigns
distinct x/y to workspaces returned at 0,0 by the API and persists via PATCH
- buildNodesAndEdges() accepts layoutOverrides map so hydration uses computed
positions instead of raw 0,0 coordinates
- canvas-events WORKSPACE_PROVISIONING grid layout replaces offset===offset
assignment that caused position:{x:t,y:t} in the minified bundle
- 8 new vitest tests cover computeAutoLayout and override behaviour (365 pass)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Walks the real UI end-to-end:
1. Creates + registers a workspace on the platform
2. Opens the detail side panel
3. Clicks the Files tab (force-click since it's in an overflow-x bar)
4. Asserts all 3 split components render:
- FilesToolbar: "+ New" + "Upload" buttons
- FileTree: the config.yaml seeded by the default template
- FileEditor: "Select a file to edit" empty-state
Saves screenshots at /tmp/filestab-{1,2,3}-*.png for manual review.
Run: cd canvas && npx playwright test e2e/filestab-smoke.spec.ts
Requires platform on :8080 + canvas on :3000.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Pure restructure — no behavior change. Extracts FileTree, FileEditor,
FilesToolbar, useFilesApi hook, and tree utilities into sibling files
under canvas/src/components/tabs/FilesTab/. Top-level FilesTab.tsx is
now 240 lines (glue + confirmations); re-exports buildTree/TreeNode so
the existing import path and tests remain stable.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Follow-up to the quality-fixes-pass2 code review.
## Go: direct unit tests for PR #5 extracted helpers (~47 new tests)
a2a_proxy_test.go:
- resolveAgentURL: cache hit, cache-miss DB hit, not-found, null-URL,
docker-rewrite guard
- dispatchA2A: build error, canvas timeout, agent timeout, success
- handleA2ADispatchError: context deadline, generic error, build error
- maybeMarkContainerDead: nil-provisioner, runtime=external short-circuits
- logA2AFailure, logA2ASuccess: activity_logs row content + status
delegation_test.go:
- bindDelegateRequest: valid / malformed / bad-UUID
- lookupIdempotentDelegation: no-key / no-match / failed-row-deleted / existing-pending
- insertDelegationRow: insertOK / insertHandledByIdempotent /
insertTrackingUnavailable
- insertDelegationOutcome: zero-value is insertOutcomeUnknown sentinel
discovery_test.go:
- discoverWorkspacePeer: online / not-found / access-denied + 2 edges
- writeExternalWorkspaceURL: 3 cases
- discoverHostPeer: smoke test documents the unreachable-by-design path
activity_test.go:
- parseSessionSearchParams: defaults + custom limit/offset/q
- buildSessionSearchQuery: no-filters + with-query shapes
- scanSessionSearchRows: empty / single / multiple rows
Package coverage: 56.1% → 57.6%. Every helper extracted in PR #5 is
now at or near 100% line coverage (see PR notes for the 4 remaining
gaps, all blocked on provisioner interface mockability).
## Defensive enum zero-value fix
insertDelegationOutcome now starts with insertOutcomeUnknown=0 as a
sentinel so an un-initialized variable can't silently read as
"success". insertOK, insertHandledByIdempotent, insertTrackingUnavailable
shift to 1/2/3. No caller changes needed.
## Canvas: ConfirmDialog.singleButton test (5 cases)
canvas/src/components/__tests__/ConfirmDialog.test.tsx covers:
- default render (both buttons)
- singleButton hides Cancel
- singleButton: Escape still fires onCancel
- singleButton: backdrop-click still fires onCancel
- singleButton: onConfirm fires on click
vitest total: 352 → 357, all passing.
## Docstring clarity
ConfirmDialog.tsx: expanded singleButton prop comment to explicitly
instruct callers to pass the same handler for onConfirm/onCancel when
using it as an info toast (matches TemplatePalette usage).
## ErrorBoundary clipboard observability
.catch(() => {}) silently swallowed rejections. Now:
.catch((e) => console.warn("clipboard write failed:", e))
so permission-denied / insecure-context failures surface in the console.
## Verification
- go build ./... clean
- go vet ./... clean
- go test -race ./internal/... — all pass
- canvas npm run build — clean
- canvas npm test -- --run — 357/357 pass
- tests/e2e/test_api.sh — 46/62 pass; all 16 failures are pre-existing
(token-auth enforcement + stale test workspaces + missing Docker
network). None involve handlers touched in PR #5.
- Manual: platform + canvas running locally, title=Molecule AI,
/workspaces returns [], /health returns ok. Identified + killed a
stale Next.js server from the old Starfire-AgentTeam repo that was
serving the old brand on IPv4 port 3000.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Post-review fixes on top of the quality-pass-2 branch.
1. delegation.go: replaced insertDelegationRow's (bool, bool) return
with a typed insertDelegationOutcome enum (insertOK /
insertHandledByIdempotent / insertTrackingUnavailable). Eliminates
the positional-boolean decoding the caller had to do. Internal, no
behavior change.
2. ConfirmDialog.tsx: added singleButton prop. When true, hides the
Cancel button for single-action info toasts (Esc still dismisses
via onCancel). TemplatePalette's import notice uses it.
3. ErrorBoundary.tsx: fixed the floating clipboard promise. Added
.catch(() => {}) so a rejected writeText (permission denied,
insecure context) doesn't surface as unhandled rejection.
4. a2a_proxy_test.go: added 5 direct unit tests for
normalizeA2APayload (invalid JSON, wraps-bare, preserves-existing-
id, preserves-existing-messageId, missing-method). Fills the unit-
test gap for the helper extracted in the last pass.
Verification:
- go test -race ./internal/handlers/... passes (incl. 5 new tests)
- go build ./... clean
- canvas npm run build clean
- canvas npm test -- --run -> 352/352
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Swap in the new molecular-graph icon across canvas favicon, in-app logo,
and README branding paths. Add HANDOFF.md as the cross-session context
doc carried over from the Starfire→Molecule AI migration. Fix stale
"Starfire" reference in the pre-commit hook header.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>