From 6851379faa05b02b0ba12c6a65a069e22357e9aa Mon Sep 17 00:00:00 2001 From: Brian Love Date: Tue, 1 Sep 2026 20:31:00 -0700 Subject: [PATCH 1/7] docs: specify control-plane production follow-ups --- ...-control-plane-production-polish-design.md | 111 +++++++ ...026-09-01-custom-runtime-targets-design.md | 297 ++++++++++-------- ...09-01-workspace-control-plane-v2-design.md | 17 +- 3 files changed, 289 insertions(+), 136 deletions(-) create mode 100644 docs/superpowers/specs/2026-09-01-control-plane-production-polish-design.md diff --git a/docs/superpowers/specs/2026-09-01-control-plane-production-polish-design.md b/docs/superpowers/specs/2026-09-01-control-plane-production-polish-design.md new file mode 100644 index 000000000..5e7dec1a6 --- /dev/null +++ b/docs/superpowers/specs/2026-09-01-control-plane-production-polish-design.md @@ -0,0 +1,111 @@ +# Unified control-plane production polish + +## Status + +Approved through interactive design review after production Chrome validation on 2026-09-01. This is the first implementation PR in the control-plane follow-up and is intentionally independent of custom runtime targets. + +## Summary + +Finish the small interaction and visual details exposed by the production audit without redesigning the unified rail and context shell. The current responsive layout, icon system, section hierarchy, rounded active states, mode history, mobile search handoff, and runtime persistence all passed validation and remain unchanged. + +The implementation centers the page-actions control as a first-class header action, gives it a 44px target at every pointer mode, adds a visible tooltip, and locks in the approved minimal heading and icon treatment through focused regression coverage. + +## Production evidence + +Chrome validation covered the Docs and Cockpit production deployments at 320, 768, 1024, and 1440 CSS pixels. + +- No tested width produced horizontal page overflow. +- Desktop rail plus context pane, tablet rail plus drawer, and mobile modal layouts behaved correctly. +- Docs, Run, Code, and API mode changes preserved the mounted runtime and browser history. +- Capability navigation and mobile Search restored focus correctly. +- Active and hover rows were fully rounded and had no left-edge marker. +- Context headings were quiet title case with readable weight and no uppercase microcopy. +- Current Lucide icons and chevrons used the approved clean 2px stroke treatment. +- The page-actions menu itself was polished and correctly contained On this page and Copy page as Markdown. +- The page-actions trigger measured 36 by 36 pixels at every tested width, below the 44px interaction baseline and visually lighter than its surrounding header controls. + +## Goals + +1. Make Page actions feel intentional in the branded page header at every responsive width. +2. Meet a minimum 44 by 44 CSS-pixel target for mouse, touch, pen, and keyboard users. +3. Preserve the icon-led menu and all existing menu behavior. +4. Add visible tooltip and focus treatment without making the tooltip the accessible name. +5. Prevent regressions to uppercase section headings, thin icons, partial active borders, or square hover states. + +## Non-goals + +- Redesigning the rail, context pane, page header, or menu information architecture. +- Adding direct On this page or Copy page buttons outside the overflow menu. +- Replacing Lucide, changing the shared 2px icon stroke, or adding decorative icons. +- Changing article typography, navigation labels, shell breakpoints, or runtime behavior. +- Implementing command palette, pins, recents, Activity filters, or custom runtime targets. +- Committing screenshot baselines from the manual production audit. + +## Page-actions control + +`PageActions` remains an icon-only ellipsis button in `DocsPageHeader`. The header retains its current article-aligned measure and places the trigger at the right edge of the branded library/section row. + +- The trigger is implemented at 44 by 44 CSS pixels at all pointer modes, not only under `pointer: coarse`; tests enforce 44 pixels as the minimum. +- The visual icon remains a Lucide horizontal ellipsis with a 2px stroke at an optically balanced 18–20px size. +- Default color is muted; hover, focus-visible, and expanded states use the existing primary text and rounded surface tokens. +- The hit area and hover/active background use the same rounded geometry as other docs controls. +- A shared-style tooltip reading `Page actions` uses the existing 120ms opacity/visibility transition with no additional delay on hover and keyboard focus. It is suppressed while the menu is open and on coarse pointers. The button's `aria-label` remains the accessible name. +- The control does not introduce a border or left-edge active marker in normal color modes. Forced colors retain the explicit system-color boundary already used by the docs controls. + +The menu retains the current order and labels: + +1. On this page. +2. Copy page as Markdown. +3. Open in ChatGPT. +4. View as Markdown. +5. Edit on GitHub. + +The menu remains right-aligned to the trigger, at least 224px wide, and clamped inside the visual viewport with safe-area spacing. Existing arrow, Home, End, Escape, outside-click, copy feedback, heading expansion, and focus-restoration behavior remains authoritative. + +## Heading and icon contract + +No broad visual change is required because the deployed hierarchy passed review. This PR records and tests the accepted contract: + +- Control-plane section headings and disclosure labels use title case, the shared sans family, 12px readable sizing, 600 weight, normal letter spacing, and muted text tokens. +- Article headings retain the existing serif treatment. +- Rail, utility, action, and disclosure icons remain Lucide icons with the shared 2px stroke and current optical sizes. +- Disclosure chevrons use the shared 15–16px treatment and rotate as a unit; no hand-drawn caret or thin text glyph is introduced. +- Active and hover rows use complete rounded backgrounds. No navigation item uses a left border or partial rounded-left marker. +- Icon-only actions retain accessible names and visible tooltips on fine pointers; mobile never depends on a tooltip as its only label. + +The contract should be asserted at the smallest stable selector or component boundary. It should not duplicate implementation tokens across multiple tests. + +## Responsive behavior + +- 1440 and 1024: Page actions remains aligned with the article header and does not drift into unused workspace width. +- 768: the 44px trigger does not collide with the context-drawer trigger or force horizontal overflow. +- 320: the trigger, branded label, breadcrumb, and menu stay within the viewport; long breadcrumb text may continue to truncate. +- Forced colors preserves a visible trigger boundary, focus, and menu selection. +- Reduced motion removes non-essential menu and tooltip transitions. + +## Error handling + +- A failed Markdown fetch leaves the menu open and returns to the normal Copy page as Markdown label; it never reports success. +- If the wide On this page table of contents is unavailable, the existing nested heading links remain the fallback. +- Tooltip failure or suppression never removes the button's accessible name. +- Menu positioning failure must not create document-level horizontal scrolling. + +## Testing + +- Extend the `PageActions` component tests for tooltip visibility/suppression, unchanged menu contents, keyboard traversal, and focus restoration. +- Add a stable CSS contract asserting the 44px trigger outside coarse-pointer media queries and the absence of a normal-mode border marker. +- Extend website Playwright coverage to assert a computed target of at least 44 by 44 pixels and a menu bounding box inside the viewport. +- Cover 320, 768, 1024, and 1440 widths in the focused shell/page-actions matrix. +- Retain existing forced-colors and reduced-motion shell coverage; add page-actions assertions only where they exercise new behavior. +- Run existing website unit, lint, build, and project-scoped E2E targets. No Cockpit code change is expected, so Cockpit coverage is a regression smoke rather than a new feature matrix. +- Repeat the production Chrome journey after deployment: Docs to Run to Code to API, back/forward, capability navigation, mobile Search, and both Docs and Cockpit control-plane drawers. + +## Acceptance criteria + +1. Page actions measures at least 44 by 44 CSS pixels at all four target widths and pointer modes. +2. The trigger is visually aligned with the branded page header and retains the approved rounded hover, expanded, and focus states. +3. A visible Page actions tooltip works for hover and keyboard focus without replacing the accessible name or appearing on coarse pointers. +4. On this page and Copy page as Markdown remain inside the three-dot menu; no quick-action row is introduced. +5. Menu keyboard behavior, copy feedback, viewport containment, and focus restoration continue to pass. +6. Automated contracts prevent uppercase micro-headings, thin or hand-drawn chevrons, partial active borders, and square navigation states from returning. +7. The focused production journey passes at 320, 768, 1024, and 1440 without horizontal overflow. diff --git a/docs/superpowers/specs/2026-09-01-custom-runtime-targets-design.md b/docs/superpowers/specs/2026-09-01-custom-runtime-targets-design.md index 9b77798c9..05ce712f6 100644 --- a/docs/superpowers/specs/2026-09-01-custom-runtime-targets-design.md +++ b/docs/superpowers/specs/2026-09-01-custom-runtime-targets-design.md @@ -1,90 +1,117 @@ -# Custom AG-UI and LangSmith runtime targets +# Memory-only custom AG-UI and LangSmith runtime targets ## Status -Approved through interactive design review on 2026-09-01. This is release 2 of the unified control-plane program and depends on the unified workspace shell. +Approved through interactive design review on 2026-09-01 and amended after production validation on the same date. This amendment replaces the earlier endpoint-persistence design: endpoint URLs and API keys are both memory-only. + +This is the second implementation PR in the control-plane follow-up. It depends on the unified workspace shell, but not on the production-polish PR. ## Summary -Let users connect the unified workspace to either their own AG-UI endpoint or a LangSmith deployment URL and API key. Endpoint metadata may be stored on the current device. API keys are memory-only: they are never persisted, placed in URLs, included in diagnostics, emitted to analytics, or forwarded through a Threadplane server. +Let users run compatible capabilities against either their own AG-UI endpoint or a LangSmith deployment URL and API key. The shared development deployment remains the default. + +Every custom value is volatile. Endpoint URLs, API URLs, and keys may survive mode and capability changes inside the current browser document, but they disappear on full refresh, top-level navigation away, tab close, or explicit clearing. They never enter local storage, session storage, IndexedDB, cookies, URLs, analytics, diagnostics, Activity, logs, or a Threadplane server. -The parent workspace remains the sole credential-management authority: it accepts, replaces, and clears credentials. It configures the mounted Angular runtime through a strict-origin, nonce-bound iframe handshake. Angular bootstraps with the selected runtime target in memory, acknowledges the exact configuration generation, and reports actionable health states back to the control plane. The trusted child Agent client may retain the key in volatile memory only for that configured generation so it can authorize requests. +The top-level workspace owns target selection and credential lifetime. It configures the mounted Angular runtime through an exact-origin, nonce-bound iframe handshake. The child builds the appropriate Agent client for that configuration generation and retains any key only inside the resulting in-memory client. Replacing or clearing the generation disposes the client reference. ## Goals 1. Support Shared development, Custom AG-UI, and Custom LangSmith targets. -2. Keep API keys memory-only across the entire browser flow. -3. Configure compatible Angular runtime examples without query parameters or browser storage. -4. Make authorization, CORS, network, configuration, and bridge failures distinguishable. -5. Preserve the existing shared-development default and standalone examples. -6. Apply one generated, drift-tested configuration contract across compatible Cockpit runtimes. +2. Keep every user-entered target value in memory only. +3. Preserve a custom target through Docs, Run, Code, API, and in-shell capability navigation without writing browser storage. +4. Configure compatible Angular examples without query parameters or credential-bearing globals. +5. Make validation, authorization, network/CORS, and bridge failures actionable without exposing secrets or remote response bodies. +6. Preserve existing shared-development and standalone-example behavior. ## Non-goals -- Remembering API keys after refresh, navigation away, or tab close. -- Account-synced targets or credential vaulting. +- Remembering any custom value after refresh, navigation away, or tab close. +- Saved target lists, target naming, account sync, or credential vaulting. +- Sharing memory between `threadplane.ai` and `cockpit.threadplane.ai` tabs or origins. - Proxying custom traffic through Threadplane infrastructure. -- Deployment management, server restart, or mutation commands. -- Arbitrary request headers, OAuth flows, or custom authentication schemes. -- Claiming compatibility for static examples with no Agent transport. +- Deployment management, server mutation, OAuth, arbitrary headers, or custom authentication schemes. +- AG-UI credentials; this release accepts only an AG-UI endpoint. +- Changing static examples that have no Agent transport. +- Proving a remote endpoint healthy before the user performs a protocol operation when that protocol has no safe standard health request. -## Target model +## Session model and lifetime + +Target state is keyed by runtime adapter so a LangSmith choice cannot block AG-UI capabilities and an AG-UI choice cannot leak into LangGraph capabilities. ```ts -type SavedRuntimeTarget = - | { id: 'shared'; kind: 'shared'; label: 'Shared development' } - | { id: string; kind: 'ag-ui'; label: string; endpoint: string } - | { id: string; kind: 'langsmith'; label: string; apiUrl: string }; - -type EphemeralCredentials = { - targetId: string; - kind: 'langsmith'; - apiKey: string; -} | null; -``` +type SharedTarget = { kind: 'shared' }; -Only `SavedRuntimeTarget` enters device-local preferences. The `EphemeralCredentials` record exists only in React state owned by the mounted workspace provider. After configuration, the child Agent client's separately scoped volatile key copy is permitted only under the generation-lifetime rules below; it is never an `EphemeralCredentials` store or credential-management surface. +type AgUiTarget = + | SharedTarget + | { kind: 'ag-ui'; endpoint: string }; -Release 2 stores target metadata in a dedicated `threadplane:runtime-targets:v1` record: +type LangGraphTarget = + | SharedTarget + | { kind: 'langsmith'; apiUrl: string; apiKey: string }; -```ts -interface RuntimeTargetPreferencesV1 { - version: 1; - selectedTargetId: string; - savedTargets: SavedRuntimeTarget[]; +interface RuntimeTargetSession { + agUi: AgUiTarget; + langgraph: LangGraphTarget; } ``` -The record contains no credential field or extension bag. Invalid selected IDs fall back to `shared`; malformed custom targets are dropped individually. Release 3 may migrate this record into the unified workspace preference schema, but release 2 keeps this narrow store independently deployable. +Both adapter slots default to `{ kind: 'shared' }`. A dedicated `RuntimeTargetProvider` owns the session above route content in each application root. The website and Cockpit each mount their own provider once per browser document. `WorkspaceProvider` consumes the provider and resolves the current capability's `runtimeAdapter` to the matching slot. + +The provider has no serializer, hydration path, storage key, URL reader, or module-global fallback. Tests fail if target fields are added to the existing control-plane preference schema. A full document reload constructs the default session again. -After refresh, a saved LangSmith target is selected but enters `credentials_required` until the key is entered again. Removing or changing a target clears any matching ephemeral credentials immediately. +Settings maintains a local draft separate from the effective session. Typing never reconfigures the runtime. `Use custom target` validates and atomically replaces the matching effective slot. `Use shared development` immediately replaces that slot with `{ kind: 'shared' }` and clears its draft, including the key. + +Closing Settings does not clear an applied custom target. Navigating to another capability with the same adapter reuses it. Navigating to the other adapter uses that adapter's independent slot. `runtimeAdapter: 'none'` always uses the existing static behavior and exposes no custom-target form. ## Endpoint validation - Require an absolute HTTP or HTTPS URL. - Allow HTTP only for `localhost`, `127.0.0.1`, and `[::1]` development targets. -- Reject URL user information, fragments, and query strings. -- Normalize trailing slashes without changing the path. -- Reject values containing control characters. -- Never echo a rejected raw value into Activity or diagnostics. -- Display the normalized origin and path only after successful validation. +- Reject URL user information, fragments, query strings, control characters, and empty values. +- Use URL parsing to normalize scheme, host casing, and default ports. Preserve the pathname exactly, including whether a non-root path ends in a slash; `/agent` and `/agent/` may be different endpoints. +- Require a non-empty key for Custom LangSmith, but never render the key outside its password input. +- Validate the draft before committing it. Invalid drafts do not replace a working target or remount the iframe. +- Never echo rejected raw values into errors, Activity, diagnostics, or analytics. +- Display the normalized origin and path only after validation succeeds. -The UI derives and displays the exact Angular runtime origin from the selected capability's iframe URL. It explains that the custom server must allow that runtime origin—not the top-level workspace origin—in `Access-Control-Allow-Origin`. Local fake servers assert the received browser `Origin`, and production smoke asserts that the displayed required origin exactly matches the mounted runtime iframe origin. The application does not attempt to bypass CORS. +The UI derives and displays the exact Angular runtime origin that the custom server must allow through CORS. It explains that this is the iframe origin, not necessarily the top-level workspace origin. The application does not attempt to bypass CORS. -Compatible runtime deployments must permit validated targets in `connect-src`. Supporting arbitrary validated HTTPS endpoints inherently requires HTTPS network egress from the child runtime; local development additionally requires the allowlisted loopback HTTP origins. Deployment tests inspect the effective policy and prove a validated fake target is reachable. This allowance never changes the exact destination chosen by the Agent client, relaxes parent-message authorization, or permits Threadplane to proxy the request. +Compatible runtime deployments must permit validated HTTPS destinations in `connect-src` and the allowlisted loopback HTTP destinations in local development. Deployment tests inspect the effective policy. This network allowance does not relax parent-message authorization or permit Threadplane to proxy a request. ## Runtime compatibility -The workspace registry adds `runtimeAdapter: 'langgraph' | 'ag-ui' | 'none'`. +The existing registry field remains authoritative: + +```ts +type RuntimeAdapter = 'langgraph' | 'ag-ui' | 'none'; +``` + +- `langgraph` entries use the LangGraph session slot and expose Custom LangSmith. +- `ag-ui` entries use the AG-UI session slot and expose Custom AG-UI. +- `none` entries show a concise explanation that runtime configuration is unavailable. +- The current manifest classification and its drift tests determine compatibility; there is no product-name heuristic or unnamed exception. +- A LangSmith target retains each capability's existing assistant or graph identifier. The user supplies only the API URL and key. + +## Settings experience + +The shared Settings utility adds a `Runtime target` section below Language and before Theme. + +For a compatible capability it contains: + +1. A two-option selector: Shared development or the adapter-compatible custom target. +2. An Endpoint field for AG-UI, or API URL and API key fields for LangSmith. +3. `Use custom target` as the explicit apply action. +4. `Use shared development` when custom configuration exists; this is the explicit clear action. +5. Inline validation and the exact runtime origin required for CORS. +6. A persistent-in-session note: `Kept in this tab until refresh. Nothing is saved.` -- LangSmith targets are available to `langgraph` entries. -- AG-UI targets are available to `ag-ui` entries. Runtime-portability entries that consume the AG-UI Agent contract are classified as `ag-ui`; there is no unnamed compatibility exception. -- `none` entries show the target selector as unavailable with a truthful explanation. -- Switching to an incompatible target is prevented before iframe configuration. +The API key uses a password input with browser autofill disabled as far as the platform permits. The UI does not offer reveal, copy, save, rename, or target-history actions. The active Runtime section shows only target kind plus sanitized origin and path. It never shows the key. + +Mobile uses the existing control-plane utility panel. Fields and actions meet the 44px target baseline, the panel owns its scroll, and closing it restores focus to Settings. ## Secure configuration protocol -Version 2 extends the existing private runtime bridge with child-ready, host-intent, configure, and acknowledge messages. Exact message names are centralized in `@threadplane/cockpit-runtime-bridge`. +Version 2 extends the existing private runtime bridge with child-ready, configure, configured, configuration-failure, and operation-failure messages. Exact message names and parsers live in `@threadplane/cockpit-runtime-bridge`. ```ts interface RuntimeChildReadyMessage { @@ -93,13 +120,6 @@ interface RuntimeChildReadyMessage { nonce: string; } -interface RuntimeHostMessage { - type: 'tplane:runtime-host'; - version: 2; - nonce: string; - generation: number; -} - interface RuntimeConfigureMessage { type: 'tplane:runtime-configure'; version: 2; @@ -117,111 +137,136 @@ interface RuntimeConfiguredMessage { nonce: string; generation: number; } + +type RuntimeFailureMessage = + | { + type: 'tplane:runtime-configuration-failed'; + version: 2; + nonce: string; + generation: number; + code: 'incompatible_bridge'; + } + | { + type: 'tplane:runtime-operation-failed'; + version: 2; + nonce: string; + generation: number; + code: 'unauthorized' | 'network_blocked'; + }; ``` -Security rules: +The full protocol follows these rules: + +- The parent sends only to the iframe's exact origin; never `*`. +- Compatible child builds receive an exact `allowedParentOrigins` array generated from repository deployment configuration for the production website, production Cockpit, supported named previews, and explicit localhost development origins. Wildcards, suffix matching, and referrer-derived additions are forbidden. +- A child is a recognized embed only when `window.parent !== window`, `document.referrer` parses to an exact member of `allowedParentOrigins`, and the incoming message source and origin match that parent. The runtime iframe keeps `referrerPolicy="origin"`, and deployment smoke verifies the referrer is not suppressed. +- The child accepts configuration only from `window.parent`, that exact recognized parent origin, the expected source window, and the current protocol version. +- A recognized child installs its listener before Angular bootstrap, creates a fresh nonce, and announces ready to its exact parent origin. A standalone window bootstraps its registry default immediately. An embedded window with a missing or unallowlisted referrer ignores configuration messages and uses its existing unrecognized-embed fallback. +- The parent listener exists before iframe navigation. It echoes the nonce and current generation in the configure message. +- Both sides validate message shape, nonce, generation, source, and origin. +- Ready and configure messages retry on bounded timers until the matching acknowledgement arrives. +- The first valid payload for a nonce and generation is authoritative. An identical duplicate only repeats the acknowledgement; a conflicting duplicate is rejected with an allowlisted failure code. +- A recognized embed fails closed if configuration does not complete before the bounded deadline. It does not silently bootstrap Shared development. +- A standalone or unrecognized embed keeps its existing registry default and ignores configuration messages. +- Messages and payloads are never logged, serialized, placed in DOM attributes, or copied into diagnostics. +- A newer generation invalidates older configure, acknowledgement, and failure messages. +- Configuration and operation failure messages contain allowlisted status codes only, never endpoint values, keys, authorization headers, error messages, or remote response text. -- The parent sends only to the exact iframe origin; never `*`. -- Compatible child builds receive an exact `allowedParentOrigins` list generated from the repository deployment configuration for production, named previews, and local development. Tests may inject explicit origins. Wildcards, suffix matching, and arbitrary referrer-derived authority are forbidden. -- The child accepts configuration only when `window.parent` is the message source, the referrer origin exactly matches one `allowedParentOrigins` entry, and the message origin equals that referrer origin. -- The unified host sets `referrerPolicy="origin"` on runtime iframes, and runtime deployment headers must not suppress that referrer. Deployment smoke verifies that the child receives the exact parent origin required for authorization. -- Both sides validate protocol version, message shape, nonce, generation, source window, and origin. -- Before Angular bootstrap, an allowed-parent child installs its listener, creates a fresh nonce, and sends `runtime-child-ready` to its exact referrer origin. The parent listener is installed before assigning the iframe source. -- The parent validates the ready message and responds to that exact source with `runtime-host` followed by `runtime-configure`, echoing the nonce and current generation. The child repeats ready and the parent repeats host/configure on bounded timers until the matching `runtime-configured` acknowledgement ends the handshake. -- The first valid configure payload accepted for a nonce and generation is authoritative. An identical duplicate re-sends `runtime-configured` without reconstructing the Agent client or repeating bootstrap. A conflicting payload for an accepted nonce and generation is rejected and reported with an allowlisted protocol error code. -- An embed whose referrer origin is allowlisted is recognized immediately and fails closed with `incompatible_bridge` if the handshake does not complete before the bounded deadline. It never bootstraps the Shared development default, even if every parent message is lost. -- A standalone window uses its registry default immediately. An embed whose referrer origin is not allowlisted is unrecognized, ignores every configuration message, and may use the existing registry default. -- The child Agent client may retain the key in a private in-memory closure or client object only for the acknowledged generation. Disposing or superseding that generation destroys the client reference; the child exposes no credential setter, reader, persistence path, diagnostic field, or serialized copy. -- Messages are never logged, serialized to diagnostics, or copied into DOM attributes. -- A newer generation invalidates every older configure or health response. -- The acknowledgement reveals no credential or endpoint value. +The protocol protects configuration transport between the known workspace and known Angular iframe. It does not make an untrusted custom endpoint safe. -The protocol does not make an untrusted custom endpoint safe. It only protects configuration transport between the unified parent and the known Angular iframe. +## Angular bootstrap and client integration -## Angular bootstrap integration +A shared pre-bootstrap target resolver is used by every compatible Cockpit application. -Add a shared Angular runtime-target provider used by all compatible Cockpit applications. +- A recognized embed waits for the valid configuration message before constructing Angular providers. +- The resolver maps Shared development to the current environment configuration. +- It maps Custom AG-UI to `provideAgent({ url: endpoint })`. +- It maps Custom LangSmith to the existing capability assistant ID plus `apiUrl` and an explicit SDK `apiKey` client option. +- `@threadplane/langgraph` adds a narrowly typed `apiKey?: string | null` client option and passes it directly to the installed LangGraph SDK `Client`. +- The runtime resolver creates a generation-bound `reportOperationFailure` callback and supplies it through private adapter integration hooks. Existing AG-UI and LangGraph error catch points call the hook only when a classifier can prove HTTP 401/403 or a fetch/network failure. The callback sends `runtime-operation-failed` through the installed bridge. Unknown application errors remain in the Angular UI and are not reported to the parent. +- The Agent client exists only for the accepted generation. Superseding or clearing the generation destroys the Angular application/client reference before mounting the replacement. +- Component-scoped Agent providers are migrated explicitly. Registry-derived drift coverage rejects compatible applications that bypass the resolver. +- Static render-only applications remain unchanged and declare `runtimeAdapter: 'none'`. -- The lightweight bridge responder installs before Angular bootstrap. -- A recognized unified embed waits for valid configuration for a bounded interval and fails closed on timeout. -- A standalone or unrecognized embed uses its existing registry default as defined by the host-detection rules above. -- A shared `provideCockpitAgent(...)` integration resolves the selected target before `bootstrapApplication(...)` and delegates to `@threadplane/langgraph` or `@threadplane/ag-ui` as declared by the registry. -- Component-scoped Agent providers are migrated explicitly; a registry-derived drift test rejects compatible applications that bypass the target provider. -- Static render-only examples remain unchanged and declare `runtimeAdapter: 'none'`. +The key may exist only in the explicitly generation-bound volatile references needed to use it: the root session state, the transient configure message, the child resolver/provider configuration, and the SDK client. JavaScript strings cannot be zeroed in place, so disposal means cancelling work and dropping every reachable reference owned by the application. No additional credential cache or reader is introduced. -The migration must cover production and Cockpit entry points. No application may read a key from `window`, URL parameters, local storage, or session storage. +Standalone examples still bootstrap immediately from their existing environment. No application reads target data from `window`, URL parameters, local storage, session storage, IndexedDB, or cookies. -## Runtime state +## Runtime state and data flow -Extend the control-plane state with: +Add the following runtime phases to the current controller: ```ts -type RuntimePhase = - | ExistingRuntimePhase - | 'credentials_required' +type CustomRuntimePhase = | 'configuring' | 'unauthorized' | 'network_blocked' | 'incompatible_bridge'; ``` -Required behavior: +Data flows as follows: -- `credentials_required` blocks mounting or configuring a LangSmith target until a key is entered. -- Clearing credentials increments generation, cancels checks, disposes the configured child client, unmounts the runtime iframe, and remains unmounted in `credentials_required` until a replacement key exists. -- `configuring` covers the nonce-bound target handshake. -- An explicit 401 or 403 response becomes `unauthorized` without exposing response bodies. -- Fetch rejection or an opaque browser failure becomes `network_blocked` and explains CORS or network causes without pretending to distinguish them. -- A configuration timeout or wrong protocol version becomes `incompatible_bridge`. -- Existing Ready, Recheck, Reload, and recovery behavior remains available after successful configuration. -- Any effective runtime configuration change—target kind, selected target ID, normalized endpoint, or LangSmith key—increments generation, cancels checks, disposes the old child client, and remounts the runtime iframe for a fresh pre-bootstrap handshake. -- Effective-configuration equality includes the selected target ID, kind, normalized endpoint, and the current in-memory key value. Selecting a different saved target therefore remounts even when two targets point to the same endpoint. -- Renaming a target, editing an unselected target, or re-selecting the already active unchanged configuration does not remount. Recheck and Reload retain their existing semantics. +1. Settings validates a draft and commits an adapter slot. +2. The provider derives an effective target for the current manifest entry. +3. Any effective change increments the configuration generation, cancels active checks, disposes the old iframe/client, and mounts a new iframe. +4. The parent and child complete the configuration handshake before Angular bootstrap. +5. The existing runtime-ready handshake continues after bootstrap. +6. The generation-bound adapter failure reporter may update the parent only with `unauthorized` or `network_blocked`; all other Agent errors remain inside the runtime. -## Settings experience - -Settings contains a Runtime target section: +Effective equality includes adapter, target kind, normalized endpoint, and—for LangSmith—the current in-memory key. Reapplying an identical configuration does not remount. Editing a draft does not remount. Recheck and Reload retain their existing semantics after configuration. -- Target type selector. -- Saved target selector. -- Add, rename, and remove custom endpoint metadata. -- Endpoint or API URL field. -- Password input for the current LangSmith key. -- Clear credentials action. -- Connection requirements and inline validation. -- The exact runtime origin the custom server must allow for CORS. +`Ready` continues to mean that the embedded runtime booted and accepted its configuration. It does not claim that an arbitrary remote server passed a universal health check. A 401 or 403 observed during an Agent operation becomes `unauthorized`. Fetch rejection or an opaque browser failure becomes `network_blocked` and explains that CORS or network policy may be responsible. A configuration timeout or protocol mismatch becomes `incompatible_bridge`. -Saving a LangSmith URL does not imply that credentials were saved. The UI labels the key `For this tab only` and shows `Credentials required after refresh`. +Using Shared development or replacing a LangSmith configuration increments generation, cancels checks, disposes the client, and unmounts the configured runtime before mounting the replacement. No stale key-bearing client may remain reachable. -## Diagnostics and privacy +## Diagnostics, Activity, and analytics -Diagnostics may include: +Diagnostics and Activity may include: - Target kind. -- Sanitized origin and path. - Adapter kind. - Runtime phase and allowlisted reason code. - Protocol version and configuration generation. -Diagnostics must not include keys, authorization headers, raw postMessage payloads, prompts, response bodies, or rejected raw URLs. Existing analytics may record target kind and allowlisted outcome only; this release does not add endpoint values or expand behavioral tracking. +They must not include endpoint origins or paths, keys, authorization headers, raw messages, raw drafts, rejected values, prompts, response bodies, or remote error text. The sanitized endpoint is visible only in the active Settings and Runtime UI for the current tab. Existing analytics may record target kind and allowlisted outcome only. This release does not add endpoint values or expand behavioral tracking. + +## Document lifecycle and browser restoration + +In-shell routing retains the root provider and therefore retains both adapter slots. A top-level navigation or reload fires `pagehide`; the provider synchronously resets both slots and clears mounted draft inputs before the document can enter the back-forward cache. A `pageshow` event with `persisted === true` defensively resets the provider again, so browser Back cannot revive a custom target from a cached document. + +The form uses non-identifying field names. Endpoint fields use `autocomplete="off"`; the password input uses `autocomplete="new-password"` to avoid treating the value as a reusable login. The application never requests browser credential storage. Browser or password-manager behavior outside the application's control is not treated as an application persistence path, but E2E verifies that the DOM and provider state are empty after reload and a back-forward-cache restoration. + +## Error handling + +- Invalid input leaves the current effective target untouched and focuses the first invalid field. +- A failed handshake keeps Settings and non-Run modes usable and offers Reload or Shared development. +- Unauthorized and network failures do not clear the user's in-memory configuration automatically; using Shared development or refreshing does. +- Navigating to `runtimeAdapter: 'none'` leaves both adapter slots untouched but never sends either to the static iframe. +- If the browser blocks required storage APIs, custom targets are unaffected because they do not use storage. ## Testing -- Pure validation tests for URL rules and credential separation. -- Preference serialization tests proving keys cannot be represented or persisted. -- Redaction tests across diagnostics, Activity, errors, and analytics property bags. -- Runtime bridge contract tests for exact parent allowlists, ready/host/configure retries, idempotent duplicates, conflicting duplicates, origin, source, nonce, generation, stale replies, lost messages, timeouts, and unknown messages. -- Angular bootstrap tests for custom configuration, standalone fallback, and component-scoped providers. +- Pure URL validation and normalization tests. +- Provider lifetime tests proving values survive in-shell mode/capability navigation and reset after provider remount. +- Lifecycle tests for `pagehide`, persisted `pageshow`, reload, and back-forward-cache restoration. +- Structural tests proving runtime-target fields cannot enter control-plane preferences or any storage serializer. +- Redaction tests across diagnostics, Activity, error objects, DOM attributes, and analytics property bags; sanitized endpoint text is permitted only in the active Settings and Runtime UI. +- Runtime bridge contract tests for exact allowlists, retries, idempotent duplicates, conflicting duplicates, origin, source, nonce, generation, stale replies, lost messages, timeouts, and unknown messages. +- `@threadplane/langgraph` tests proving the explicit key reaches the SDK `Client` and never appears in public diagnostics. +- Angular bootstrap tests for custom configuration, generation replacement, disposal, standalone fallback, and component-scoped providers. - Registry-derived coverage for every compatible Angular application. -- Browser E2E with local fake AG-UI and LangSmith servers, including exact request-origin assertions, CSP reachability, Ready, acknowledgement loss, unauthorized, CORS/network failure, refresh, live credential clearing, and target switching. -- Production smoke continues to use Shared development, asserts the displayed CORS origin equals the mounted iframe origin, and verifies the deployed child referrer and connection policies. No real user key is required in CI. +- Browser E2E with local fake AG-UI and LangSmith servers, including CORS origin assertions, CSP reachability, successful streaming, 401/403, network failure, reload clearing, live clearing, same-adapter navigation, and cross-adapter navigation. +- Production smoke continues to use Shared development and verifies that custom controls exist without entering a real endpoint or key. +- Repository scans reject runtime target storage keys and credential-bearing URL/message/log patterns. +- Because `@threadplane/langgraph` gains a public client option, run the smallest relevant API-doc and public agent-context generators and review their diffs. ## Acceptance criteria -1. A user can run a compatible workspace against a custom AG-UI endpoint. -2. A user can run a compatible workspace against a LangSmith URL and tab-memory API key. -3. Refresh retains endpoint metadata and forgets the key. -4. Repository search and automated tests prove no key persistence or URL transport path exists. -5. Every compatible Angular runtime uses the shared target integration. -6. Failure states are actionable and contain no secret or arbitrary remote response text. -7. Shared development and standalone example behavior remain unchanged. +1. A user can run an AG-UI-compatible capability against a custom AG-UI endpoint. +2. A user can run a LangGraph-compatible capability against a LangSmith URL and API key. +3. Both configurations survive in-shell navigation within the current document and disappear after full refresh, top-level navigation away, back-forward-cache restoration, or tab close. +4. Automated tests and repository scans prove that endpoint URLs and keys have no persistence, URL, analytics, diagnostics, Activity, or logging path. +5. Every compatible Angular runtime uses the shared pre-bootstrap resolver. +6. Replacing or clearing a target disposes the prior key-bearing client and rejects stale bridge messages. +7. Failure states are actionable and contain no secret or arbitrary remote response text. +8. Shared development and standalone example behavior remain unchanged. diff --git a/docs/superpowers/specs/2026-09-01-workspace-control-plane-v2-design.md b/docs/superpowers/specs/2026-09-01-workspace-control-plane-v2-design.md index 1a5a66ce7..28df323dd 100644 --- a/docs/superpowers/specs/2026-09-01-workspace-control-plane-v2-design.md +++ b/docs/superpowers/specs/2026-09-01-workspace-control-plane-v2-design.md @@ -30,10 +30,9 @@ The design remains rail-first and context-led. It does not become a dashboard, l ## Preference model -Create a versioned preference migration from both predecessor records: +Create a versioned preference migration from `threadplane:control-plane:v1`, whose exact `ControlPlanePreferencesV1` shape contains Docs and Cockpit disclosure state plus `cockpit.activeMode`. -- `threadplane:control-plane:v1`, whose exact `ControlPlanePreferencesV1` shape contains Docs and Cockpit disclosure state plus `cockpit.activeMode`. -- `threadplane:runtime-targets:v1`, whose exact `RuntimeTargetPreferencesV1` shape contains `selectedTargetId` and `savedTargets` but no credentials. +Runtime target kind, endpoint URL, API URL, and API key remain structurally absent from every preference record. The memory-only runtime-target provider has no migration or storage path. The new authoritative `threadplane:workspace-preferences:v2` record stores only non-secret state: @@ -45,8 +44,6 @@ interface WorkspacePreferencesV2 { pins: string[]; recents: string[]; activityFilter: 'all' | 'runtime' | 'navigation' | 'errors'; - selectedTargetId: string; - savedTargets: SavedRuntimeTarget[]; } ``` @@ -59,8 +56,8 @@ Rules: - Recents are unique, newest first, and updated only on a successful workspace navigation. - The current workspace may appear in Pinned but is not duplicated in Recent rendering. - A failed or malformed migration falls back to defaults without deleting unrelated browser data. -- When no v2 record exists, migration validates each predecessor independently, preserves every valid disclosure and target field, maps `cockpit.activeMode` to `lastMode`, and writes v2 only after constructing a complete valid record. The predecessor keys remain during this release for rollback but are no longer authoritative after a successful v2 write. -- Activity events and API keys are structurally absent from the preference type. +- When no v2 record exists, migration validates the predecessor, preserves every valid disclosure field, maps `cockpit.activeMode` to `lastMode`, and writes v2 only after constructing a complete valid record. The predecessor key remains during this release for rollback but is no longer authoritative after a successful v2 write. +- Activity events, runtime endpoints, target kinds, and API keys are structurally absent from the preference type. - On initial navigation, an explicit valid `mode` query wins, followed by the canonical route default from release 1. `lastMode` never overrides a deep link or the Docs default; it is used only by mode-preserving in-shell navigation that has no explicit destination mode. ## Context hierarchy @@ -91,7 +88,7 @@ Palette groups: - Modes. - Safe runtime commands. -Safe runtime commands are limited to Recheck, Reload runtime, Open runtime, Copy diagnostics, and Configure runtime. Disabled commands remain discoverable with a reason. Clearing Activity stays in the Activity menu. Removing targets and clearing credentials stay in Settings. +Safe runtime commands are limited to Recheck, Reload runtime, Open runtime, Copy diagnostics, and Configure runtime. Disabled commands remain discoverable with a reason. Clearing Activity stays in the Activity menu. Switching back to Shared development remains in Settings. Behavior: @@ -166,7 +163,7 @@ Add focused Chromium, Firefox, and WebKit shell E2E. The full capability matrix ## Testing -- Two-record-to-v2 preference migration, field preservation, route precedence, bounds, deduplication, and malformed storage tests. +- V1-to-v2 preference migration, field preservation, route precedence, bounds, deduplication, malformed storage, and runtime-target exclusion tests. - Pure palette indexing and matching tests. - Docs search parity and single-shortcut-handler tests. - Component tests for keyboard traversal, focus restoration, pressed pin state, filtered Activity, and menu placement. @@ -180,7 +177,7 @@ Add focused Chromium, Firefox, and WebKit shell E2E. The full capability matrix ## Acceptance criteria 1. A keyboard-only user can reach any capability, mode, pin, recent item, or safe runtime command through the palette. -2. Pins and recents survive refresh without storing Activity or credentials. +2. Pins and recents survive refresh without storing Activity, runtime endpoints, target kinds, or credentials. 3. Activity filters work without mutating history or attention state. 4. No active navigation treatment uses a left border or partial rounded edge. 5. Page-specific secondary actions remain in a polished three-dot menu. From 0617398c58b057a4f96ae14685926530dbd12eb8 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Tue, 1 Sep 2026 20:44:21 -0700 Subject: [PATCH 2/7] docs: specify cockpit surface retirement --- ...09-01-cockpit-surface-retirement-design.md | 214 ++++++++++++++++++ ...-control-plane-production-polish-design.md | 1 + ...026-09-01-custom-runtime-targets-design.md | 9 +- ...26-09-01-unified-workspace-shell-design.md | 10 +- 4 files changed, 224 insertions(+), 10 deletions(-) create mode 100644 docs/superpowers/specs/2026-09-01-cockpit-surface-retirement-design.md diff --git a/docs/superpowers/specs/2026-09-01-cockpit-surface-retirement-design.md b/docs/superpowers/specs/2026-09-01-cockpit-surface-retirement-design.md new file mode 100644 index 000000000..187295838 --- /dev/null +++ b/docs/superpowers/specs/2026-09-01-cockpit-surface-retirement-design.md @@ -0,0 +1,214 @@ +# Cockpit surface retirement and Website workspace cutover + +## Status + +Approved through interactive design review on 2026-09-01 after the unified Website workspace reached production parity. This is the second implementation PR in the control-plane follow-up, after production polish and before memory-only custom runtime targets. + +This specification completes migration steps 6 and 7 of the unified workspace shell design. + +## Summary + +Make `threadplane.ai` the only user-facing Docs and operational workspace. Remove the separate `Examples` navigation link and every Website-to-Cockpit handoff. Convert `cockpit.threadplane.ai` into a permanent, registry-derived redirect service so old bookmarks and external links continue to work without preserving a second product surface. + +`examples.threadplane.ai` is not retired. It remains the execution origin for Angular runtimes embedded by the Website workspace. The standalone `demo.threadplane.ai` and `ag-ui.threadplane.ai` demos also remain available through the existing Demos menu. + +The redirect domain remains indefinitely. The interactive Cockpit application, its separate analytics/bootstrap code, and its duplicate shell deployment are retired once equivalent Website smoke coverage is green. + +## Goals + +1. Establish `threadplane.ai` as the sole canonical product surface for Docs, Run, Code, API, Runtime, Activity, and Settings. +2. Remove the redundant `Examples` header and footer links without adding another generic workspace destination. +3. Replace every intentional Cockpit link with a same-origin canonical Docs or workspace route. +4. Preserve known Cockpit deep links with permanent, registry-derived redirects and truthful mode semantics. +5. Move production confidence from the duplicate Cockpit shell to the unified Website shell. +6. Keep the examples runtime deployment and standalone demos unchanged. +7. Narrow the following custom-runtime-target release to the Website host only. + +## Non-goals + +- Retiring `examples.threadplane.ai`, `demo.threadplane.ai`, or `ag-ui.threadplane.ai`. +- Moving Angular example source out of the repository's `cockpit/` tree. +- Adding a new `/examples` or `/workspace` landing page solely to replace the removed header link. +- Redirecting unknown Cockpit paths to an unrelated capability. +- Preserving arbitrary Cockpit query parameters. +- Changing capability content, runtime adapters, or deployment URLs. +- Rewriting the shared workspace library that already powers the Website. +- Adding redirect analytics or user tracking. + +## Canonical host roles + +After cutover, host responsibilities are: + +| Host | Role | +| --- | --- | +| `threadplane.ai` | Canonical Website, Docs, and operational workspace | +| `cockpit.threadplane.ai` | Redirect-only compatibility domain | +| `examples.threadplane.ai` | Embedded Angular runtime assets and application origin | +| `demo.threadplane.ai` | Standalone LangGraph demo | +| `ag-ui.threadplane.ai` | Standalone AG-UI demo | + +The Website owns every user-facing capability URL. The registry remains the authority for legacy Cockpit paths, canonical Docs paths, workspace-only paths, supported modes, and default modes. + +## Navigation and content cutover + +### Global navigation + +- Remove `Examples` from desktop and mobile header navigation. Do not rename or replace it. +- Remove `Examples` from the Product column in the footer. +- Keep the existing Demos menu and its two standalone demo destinations. +- Keep GitHub, Docs, Pricing, and marketing navigation unchanged. + +The control-plane rail and context navigation are now the capability discovery surface. Adding a second generic header destination would recreate the split the unified shell removed. + +### Website links + +Replace all hard-coded `https://cockpit.threadplane.ai` links in Website source and authored content: + +- Capability-specific CTAs deep-link to the corresponding canonical route and requested mode. +- A capability with a primary Docs page uses that Docs path, for example `/docs/langgraph/guides/streaming?mode=run`. +- A secondary or Docs-less capability uses its registry-owned workspace path, for example `/workspace/ag-ui/json-render?mode=run`. +- Generic marketing CTAs that previously opened Cockpit home use the representative streaming Run route only when their copy promises a live workspace. Otherwise they point to Docs or are removed. +- Blog and documentation links retain their capability identity rather than collapsing to a generic destination. + +Links are internal Next links or same-origin anchors and do not open a new tab. Authored copy stops calling Cockpit a separate hosted product. Repository paths such as `cockpit/langgraph/...` may continue to use the word Cockpit because they name source directories, not the retired URL surface. + +### Docs-only pages + +The legacy `DocsControlPlane` on `/docs` and `/docs/choosing-an-adapter` must not send Run, Code, or API to Cockpit home. These pages have no mapped capability, so operational modes remain disabled with the existing truthful explanation. Search and explicit standalone-demo actions remain available. + +Delete the fallback rule that treated an unmapped page as a Cockpit-home handoff. + +## Internal route helpers and analytics + +Replace the cross-origin `cockpit-links` boundary with registry-derived Website destinations: + +- `buildCockpitModeHref` becomes a same-origin workspace destination helper or is deleted where the mounted `WorkspaceProvider` already owns mode changes. +- `NEXT_PUBLIC_COCKPIT_BASE_URL` and the Cockpit environment-label dependency are removed from Website navigation code. +- `docsCockpitHandoff` and `docs:cockpit_handoff` are removed after their final call sites disappear. +- Mounted workspace mode changes continue through the existing workspace mode-change analytics. Static marketing CTAs use the existing CTA contract with a relative destination. +- No event records the redirect source URL or arbitrary query data. + +One helper must derive both direct Website links and legacy redirect destinations from `WorkspaceResolution`; link code must not reconstruct five-segment Cockpit paths or duplicate the primary-capability mapping. + +## Legacy redirect contract + +`cockpit.threadplane.ai` keeps only a minimal Next application or equivalent redirect deployment. It does not render the shared shell, load Angular runtimes, bootstrap product analytics, or expose interactive metadata pages. + +### Known capability paths + +For an exact `legacyPath` in the manifest: + +1. Resolve the entry through `resolveLegacyPath`. +2. Derive the Website path through `getWorkspaceDestinationPath`. +3. Determine the destination mode. +4. Issue a permanent redirect using the framework's explicit permanent-redirect API. + +Mode rules: + +- A single explicit `mode=docs|run|code|api` is preserved only when that mode is available for the resolved entry. +- With no mode, preserve the old Cockpit route default: Run for runnable entries and Docs for narrative-only entries. +- Invalid, unavailable, or duplicate mode values fall back to that old route default. +- All unrelated query parameters are discarded. Incoming fragments do not participate in route resolution; browser fragment behavior follows the platform's redirect rules and is not part of the compatibility contract. + +Destination serialization is canonical and route-aware: + +- Docs mode on a canonical `/docs/...` destination has no `mode` query. +- Docs mode on a `/workspace/...` destination uses `?mode=docs`, because that route otherwise defaults to Run. +- Run, Code, and API use their lowercase `mode` query on either route kind. +- The same serializer is used by in-Website links, mode history, and legacy redirects so reload and Back/Forward cannot disagree about the selected panel. + +Examples: + +```text +/langgraph/core-capabilities/streaming/overview/python + -> https://threadplane.ai/docs/langgraph/guides/streaming?mode=run + +/langgraph/core-capabilities/streaming/overview/python?mode=code + -> https://threadplane.ai/docs/langgraph/guides/streaming?mode=code + +/ag-ui/core-capabilities/json-render/overview/python?mode=run&utm_source=x + -> https://threadplane.ai/workspace/ag-ui/json-render?mode=run +``` + +### Root and unknown paths + +- `/` permanently redirects to the representative streaming Run route: `https://threadplane.ai/docs/langgraph/guides/streaming?mode=run`. +- An unknown, malformed, or partial legacy path returns 404. It never guesses a capability or redirects to Docs home. +- Asset and favicon routes may use same-origin redirect or static behavior only when they cannot influence the Website destination. + +### Destination origin + +The redirect service accepts one exact configured Website origin. Production requires `https://threadplane.ai`; local development may use an explicit HTTP localhost origin. The value must contain no credentials, path, query, or fragment. An invalid or missing production origin fails the build or deployment smoke rather than falling back to a request-controlled authority. + +The redirect response never derives host or protocol from `Host`, `Forwarded`, `X-Forwarded-Host`, referrer, or user input. + +## Application retirement + +Remove the interactive responsibilities from `apps/cockpit`: + +- Cockpit shell route composition and host adapters. +- Cockpit-only navigation, theme, analytics bootstrap, session ID, metadata, and Open Graph presentation. +- Duplicate shell and pane-rendering tests after their equivalent Website coverage exists. +- Production code paths that mount `WorkspaceProvider` from the Cockpit application. + +Keep only the smallest redirect application boundary and registry mapping tests needed to serve the compatibility domain. The shared `workspace-react`, `cockpit-shell`, `cockpit-registry`, `cockpit-runtime-bridge`, and example projects remain because the Website and embedded runtimes consume them. + +The `apps/cockpit` project name may remain for the redirect deployment during this release. Renaming the project or Vercel project is unnecessary churn and is not required for retirement. + +## CI and deployment sequence + +The production pipeline already deploys Website before Cockpit. Preserve that order and validate the permanent mapping before it can reach the production alias: + +1. Build, deploy, and smoke the Website containing all replacement links and integrated shell coverage. +2. Build and deploy the redirect-only Cockpit application to a non-production preview URL. +3. Against that preview, exhaustively verify every manifest legacy path plus root, mode variants, hostile authorities, and unknown-path 404 behavior. +4. Promote that already-verified redirect build to the production Cockpit alias. +5. Smoke exact production redirects for root and representative Docs-backed and workspace-only capabilities. +6. Smoke the unified Website journey and the examples runtime host. +7. Advance the deployment marker only after all checks pass. + +The former Cockpit production smoke matrix moves to Website and examples-host coverage: + +- Website owns Docs, Run, Code, API, Runtime, Activity, Settings, responsive shell, and history. The following release adds custom-target control coverage to this same suite. +- `examples.threadplane.ai` retains Angular application reachability and live-provider canaries. +- The Cockpit deployment owns only redirect status and `Location` correctness. + +The permanent cutover is intentionally one-way at the legacy URLs. Before production promotion, a failure aborts the redirect deployment and leaves the old Cockpit deployment untouched. After a 308 reaches clients, recovery never assumes that cached redirects can be recalled or that the interactive Cockpit shell can be restored reliably. A Website regression rolls the Website back to its last green build while keeping destination paths stable. A bad redirect mapping receives a forward fix at both the Website destination and redirect service as needed. The redirect service does not retain a hidden interactive-shell feature flag after cutover. + +## Custom runtime target dependency + +The following custom-target PR implements `RuntimeTargetProvider` only in the Website root. Production allowed-parent origins for the configuration bridge contain the Website origin, supported Website previews, and explicit localhost/test origins. They do not contain the retired production Cockpit origin. + +The redirect domain never receives a custom endpoint or key and never embeds a runtime. + +## Error handling + +- Invalid Website-origin configuration stops the redirect deployment. +- Unknown legacy paths return 404. +- A known path with an invalid mode redirects using its truthful old default. +- A registry entry with no valid Website destination fails registry/build validation rather than shipping a broken redirect. +- Link migration tests fail on new user-facing `cockpit.threadplane.ai` references. +- If Website smoke fails, the pipeline does not deploy the redirect-only Cockpit build. + +## Testing + +- Registry unit tests for every `legacyPath` to Website-destination round trip. +- Redirect tests for permanent status, exact `Location`, old default mode, explicit modes, unavailable modes, duplicate modes, query stripping, root, unknown paths, and hostile authority headers. +- Website component tests proving the desktop/mobile header and footer contain no `Examples` link while the Demos menu remains. +- Docs-only control-plane tests proving unavailable modes are disabled and never receive an external fallback. +- Link and analytics tests for same-origin capability destinations and removal of Cockpit handoff events. +- A source/content guard that rejects new user-facing `https://cockpit.threadplane.ai` references outside the redirect application, migration fixtures, and historical documents that are explicitly allowlisted. +- Website E2E for Docs -> Run -> Code -> API, history, capability navigation, Activity, Settings, mobile Search, and the four target widths. +- Production redirect smoke for root, a Docs-backed capability, a workspace-only capability, and an unknown path. +- Existing examples-host smoke remains unchanged except for moving ownership out of the Cockpit-shell suite. + +## Acceptance criteria + +1. Website desktop and mobile navigation contain no separate `Examples` link. +2. No user-facing Website CTA or authored link intentionally navigates to the Cockpit domain. +3. Known Cockpit URLs permanently redirect to exact registry-derived Website destinations and preserve truthful mode behavior. +4. Unknown Cockpit paths return 404 and cannot become open redirects. +5. `cockpit.threadplane.ai` renders no interactive shell and loads no embedded runtime. +6. `examples.threadplane.ai` and both standalone demo hosts continue to work. +7. Website production smoke owns every former Cockpit shell journey before the duplicate shell is removed. +8. Custom runtime targets require only the Website as their production parent surface. diff --git a/docs/superpowers/specs/2026-09-01-control-plane-production-polish-design.md b/docs/superpowers/specs/2026-09-01-control-plane-production-polish-design.md index 5e7dec1a6..1149dfa61 100644 --- a/docs/superpowers/specs/2026-09-01-control-plane-production-polish-design.md +++ b/docs/superpowers/specs/2026-09-01-control-plane-production-polish-design.md @@ -39,6 +39,7 @@ Chrome validation covered the Docs and Cockpit production deployments at 320, 76 - Replacing Lucide, changing the shared 2px icon stroke, or adding decorative icons. - Changing article typography, navigation labels, shell breakpoints, or runtime behavior. - Implementing command palette, pins, recents, Activity filters, or custom runtime targets. +- Removing Cockpit links or retiring the Cockpit surface; that is the following dedicated PR. - Committing screenshot baselines from the manual production audit. ## Page-actions control diff --git a/docs/superpowers/specs/2026-09-01-custom-runtime-targets-design.md b/docs/superpowers/specs/2026-09-01-custom-runtime-targets-design.md index 05ce712f6..6614cdfdf 100644 --- a/docs/superpowers/specs/2026-09-01-custom-runtime-targets-design.md +++ b/docs/superpowers/specs/2026-09-01-custom-runtime-targets-design.md @@ -4,7 +4,7 @@ Approved through interactive design review on 2026-09-01 and amended after production validation on the same date. This amendment replaces the earlier endpoint-persistence design: endpoint URLs and API keys are both memory-only. -This is the second implementation PR in the control-plane follow-up. It depends on the unified workspace shell, but not on the production-polish PR. +This is the third implementation PR in the control-plane follow-up. It depends on the unified workspace shell and Cockpit surface retirement, but not on the production-polish implementation details. ## Summary @@ -27,7 +27,6 @@ The top-level workspace owns target selection and credential lifetime. It config - Remembering any custom value after refresh, navigation away, or tab close. - Saved target lists, target naming, account sync, or credential vaulting. -- Sharing memory between `threadplane.ai` and `cockpit.threadplane.ai` tabs or origins. - Proxying custom traffic through Threadplane infrastructure. - Deployment management, server mutation, OAuth, arbitrary headers, or custom authentication schemes. - AG-UI credentials; this release accepts only an AG-UI endpoint. @@ -55,7 +54,7 @@ interface RuntimeTargetSession { } ``` -Both adapter slots default to `{ kind: 'shared' }`. A dedicated `RuntimeTargetProvider` owns the session above route content in each application root. The website and Cockpit each mount their own provider once per browser document. `WorkspaceProvider` consumes the provider and resolves the current capability's `runtimeAdapter` to the matching slot. +Both adapter slots default to `{ kind: 'shared' }`. A dedicated `RuntimeTargetProvider` owns the session above route content in the Website application root and mounts once per browser document. `WorkspaceProvider` consumes the provider and resolves the current capability's `runtimeAdapter` to the matching slot. The redirect-only Cockpit domain never mounts this provider. The provider has no serializer, hydration path, storage key, URL reader, or module-global fallback. Tests fail if target fields are added to the existing control-plane preference schema. A full document reload constructs the default session again. @@ -158,7 +157,7 @@ type RuntimeFailureMessage = The full protocol follows these rules: - The parent sends only to the iframe's exact origin; never `*`. -- Compatible child builds receive an exact `allowedParentOrigins` array generated from repository deployment configuration for the production website, production Cockpit, supported named previews, and explicit localhost development origins. Wildcards, suffix matching, and referrer-derived additions are forbidden. +- Compatible child builds receive an exact `allowedParentOrigins` array generated from repository deployment configuration for the production Website, supported Website previews, and explicit localhost development origins. The retired production Cockpit origin is not included. Wildcards, suffix matching, and referrer-derived additions are forbidden. - A child is a recognized embed only when `window.parent !== window`, `document.referrer` parses to an exact member of `allowedParentOrigins`, and the incoming message source and origin match that parent. The runtime iframe keeps `referrerPolicy="origin"`, and deployment smoke verifies the referrer is not suppressed. - The child accepts configuration only from `window.parent`, that exact recognized parent origin, the expected source window, and the current protocol version. - A recognized child installs its listener before Angular bootstrap, creates a fresh nonce, and announces ready to its exact parent origin. A standalone window bootstraps its registry default immediately. An embedded window with a missing or unallowlisted referrer ignores configuration messages and uses its existing unrecognized-embed fallback. @@ -176,7 +175,7 @@ The protocol protects configuration transport between the known workspace and kn ## Angular bootstrap and client integration -A shared pre-bootstrap target resolver is used by every compatible Cockpit application. +A shared pre-bootstrap target resolver is used by every compatible embedded Angular example application. - A recognized embed waits for the valid configuration message before constructing Angular providers. - The resolver maps Shared development to the current environment configuration. diff --git a/docs/superpowers/specs/2026-09-01-unified-workspace-shell-design.md b/docs/superpowers/specs/2026-09-01-unified-workspace-shell-design.md index 1aa84023f..5e7fa6bfe 100644 --- a/docs/superpowers/specs/2026-09-01-unified-workspace-shell-design.md +++ b/docs/superpowers/specs/2026-09-01-unified-workspace-shell-design.md @@ -2,7 +2,7 @@ ## Status -Approved through interactive design review on 2026-09-01. This is release 1 of the unified control-plane program. +Approved through interactive design review on 2026-09-01. Release 1 shipped in PR #949. The Cockpit surface retirement design now governs migration steps 6 and 7. ## Summary @@ -130,7 +130,7 @@ No library may import from an application directory. - Canonical metadata for docs pages omits `mode`. - Unknown or incompatible modes fall back to the route's truthful default. -Legacy Cockpit paths map through the registry. Redirect activation is a separate, reversible deployment step after production smoke proves the new destination. +Legacy Cockpit paths map through the registry. Release 1 kept redirect activation separate and reversible while production parity was established. The follow-up retirement design governs the later, exhaustively preflighted permanent cutover and its forward-recovery contract. ## Information architecture @@ -162,8 +162,8 @@ Docs page actions stay in the existing top-right ellipsis menu. The main article 3. Render the existing Docs mode inside the workspace shell on website docs routes. 4. Enable Run, Code, and API for mapped pages and add `/workspace/...` routes. 5. Reach unit, E2E, accessibility, and production-smoke parity. -6. Activate registry-driven redirects from legacy Cockpit URLs. -7. Observe production before removing the fallback deployment in a later task. +6. Activate registry-driven redirects from legacy Cockpit URLs as specified by `2026-09-01-cockpit-surface-retirement-design.md`. +7. Remove the fallback interactive surface while retaining the redirect compatibility domain, as specified by that follow-up. Each step must be deployable without a flag-day dependency on the next. @@ -173,7 +173,7 @@ Each step must be deployable without a flag-day dependency on the next. - A mode panel failure is contained by a panel boundary and does not replace Docs or navigation. - Existing Runtime and Activity error boundaries remain active. - Invalid legacy routes return the existing not-found behavior rather than a guessed redirect. -- A failed redirect rollout can be reversed without rebuilding the unified workspace. +- Before permanent cutover, a failed redirect rollout leaves the legacy deployment untouched. After permanent 308 promotion, recovery follows the retirement design and preserves stable Website destinations rather than assuming cached redirects can be recalled. ## Testing From f8f8c9517e9bd7234f0d76f3119f9e605cc73c91 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Tue, 1 Sep 2026 22:54:46 -0700 Subject: [PATCH 3/7] fix: polish workspace page actions --- apps/website/e2e/docs-shell.spec.ts | 206 ++++++++++++++++++ .../components/docs/DocsControlPlane.spec.tsx | 82 +++++++ .../src/components/docs/PageActions.spec.tsx | 60 +++++ .../src/components/docs/PageActions.tsx | 21 +- apps/website/src/styles/docs.css | 44 +++- .../src/styles/style-contracts.spec.ts | 94 ++++++++ .../lib/control-plane/control-plane.spec.tsx | 4 + .../cockpit-control-plane.spec.tsx | 28 +++ .../sidebar/navigation-groups.spec.tsx | 61 +++++- libs/workspace-react/src/styles/workspace.css | 6 + 10 files changed, 602 insertions(+), 4 deletions(-) diff --git a/apps/website/e2e/docs-shell.spec.ts b/apps/website/e2e/docs-shell.spec.ts index b0fcd811d..d026230bc 100644 --- a/apps/website/e2e/docs-shell.spec.ts +++ b/apps/website/e2e/docs-shell.spec.ts @@ -1,6 +1,13 @@ import { test, expect } from '@playwright/test'; const ARTICLE = '/docs/langgraph/getting-started/introduction'; +const PAGE_ACTION_LABELS = [ + 'On this page', + 'Copy page as Markdown', + 'Open in ChatGPT', + 'View as Markdown', + 'Edit on GitHub', +]; async function expectWorkspaceReady(page: import('@playwright/test').Page) { await expect(page.locator('[data-workspace-shell]')).toHaveAttribute( @@ -115,3 +122,202 @@ test.describe('docs shell layout', () => { expect(header).toBe(prevNext); }); }); + +test.describe('Page actions', () => { + for (const width of [320, 768, 1024, 1440]) { + test(`stays aligned and contained at ${width}px`, async ({ page }) => { + await page.setViewportSize({ width, height: 900 }); + await page.goto(ARTICLE); + await expectWorkspaceReady(page); + + const header = page.locator('.docs-page-header'); + const trigger = page.getByRole('button', { name: 'Page actions' }); + await expect(header).toBeVisible(); + await expect(trigger).toBeVisible(); + + const [headerBox, triggerBox] = await Promise.all([ + header.boundingBox(), + trigger.boundingBox(), + ]); + expect(headerBox).not.toBeNull(); + expect(triggerBox).not.toBeNull(); + if (!headerBox || !triggerBox) throw new Error('Expected Page actions geometry'); + + expect(triggerBox.width).toBeGreaterThanOrEqual(44); + expect(triggerBox.height).toBeGreaterThanOrEqual(44); + expect(triggerBox.x).toBeGreaterThanOrEqual(headerBox.x); + expect(triggerBox.x + triggerBox.width).toBeLessThanOrEqual( + headerBox.x + headerBox.width, + ); + expect( + Math.abs( + triggerBox.x + triggerBox.width - + (headerBox.x + headerBox.width), + ), + ).toBeLessThanOrEqual(1); + expect(triggerBox.y).toBeGreaterThanOrEqual(headerBox.y); + expect(triggerBox.y + triggerBox.height).toBeLessThanOrEqual( + headerBox.y + headerBox.height, + ); + expect( + Math.abs( + triggerBox.y + triggerBox.height / 2 - + (headerBox.y + headerBox.height / 2), + ), + ).toBeLessThanOrEqual(1); + + await trigger.click(); + const menu = page.getByRole('menu'); + await expect(menu).toBeVisible(); + await expect + .poll(() => + menu + .getByRole('menuitem') + .evaluateAll((items) => items.map((item) => item.textContent?.trim())), + ) + .toEqual(PAGE_ACTION_LABELS); + + const menuBox = await menu.boundingBox(); + expect(menuBox).not.toBeNull(); + if (!menuBox) throw new Error('Expected open Page actions menu geometry'); + const viewport = await page.evaluate(() => ({ + height: window.visualViewport?.height ?? window.innerHeight, + width: window.visualViewport?.width ?? window.innerWidth, + x: window.visualViewport?.offsetLeft ?? 0, + y: window.visualViewport?.offsetTop ?? 0, + })); + const safeInset = 8; + expect(menuBox.x).toBeGreaterThanOrEqual(viewport.x + safeInset); + expect(menuBox.y).toBeGreaterThanOrEqual(viewport.y + safeInset); + expect(menuBox.x + menuBox.width).toBeLessThanOrEqual( + viewport.x + viewport.width - safeInset, + ); + expect(menuBox.y + menuBox.height).toBeLessThanOrEqual( + viewport.y + viewport.height - safeInset, + ); + + const documentGeometry = await page.evaluate(() => { + const articleScroller = document.querySelector('.docs-workspace-article'); + if (!(articleScroller instanceof HTMLElement)) { + throw new Error('Expected docs article scroller'); + } + return { + articleClientWidth: articleScroller.clientWidth, + articleScrollWidth: articleScroller.scrollWidth, + rootClientWidth: document.documentElement.clientWidth, + rootScrollWidth: document.documentElement.scrollWidth, + }; + }); + expect(documentGeometry.articleScrollWidth).toBeLessThanOrEqual( + documentGeometry.articleClientWidth, + ); + expect(documentGeometry.rootScrollWidth).toBeLessThanOrEqual( + documentGeometry.rootClientWidth, + ); + }); + } + + test('reveals its tooltip for fine-pointer hover and keyboard focus', async ({ + page, + }) => { + await page.setViewportSize({ width: 1024, height: 900 }); + await page.goto(ARTICLE); + await expectWorkspaceReady(page); + expect(await page.evaluate(() => matchMedia('(pointer: fine)').matches)).toBe(true); + + const trigger = page.getByRole('button', { name: 'Page actions' }); + const tooltip = page.getByRole('tooltip', { name: 'Page actions' }); + await expect(tooltip).toBeHidden(); + + await trigger.hover(); + await expect(tooltip).toBeVisible(); + await page.mouse.move(1, 1); + await expect(tooltip).toBeHidden(); + + await trigger.focus(); + await page.keyboard.press('Shift+Tab'); + await page.keyboard.press('Tab'); + await expect(trigger).toBeFocused(); + expect(await trigger.evaluate((element) => element.matches(':focus-visible'))).toBe( + true, + ); + await expect(tooltip).toBeVisible(); + + await page.keyboard.press('Enter'); + await expect(page.getByRole('menu')).toBeVisible(); + await expect(page.getByRole('tooltip')).toHaveCount(0); + + await page.keyboard.press('Escape'); + await expect(page.getByRole('menu')).toHaveCount(0); + await expect(trigger).toBeFocused(); + await expect(tooltip).toBeVisible(); + }); + + test('keeps a visible system-color trigger boundary and focus indicator', async ({ + page, + }) => { + await page.emulateMedia({ forcedColors: 'active' }); + await page.setViewportSize({ width: 1024, height: 900 }); + await page.goto(ARTICLE); + await expectWorkspaceReady(page); + + const trigger = page.getByRole('button', { name: 'Page actions' }); + await trigger.focus(); + await expect(page.getByRole('tooltip', { name: 'Page actions' })).toBeVisible(); + const styles = await trigger.evaluate((element) => { + const reference = document.createElement('div'); + reference.style.color = 'CanvasText'; + reference.style.backgroundColor = 'Canvas'; + reference.style.outline = '2px solid Highlight'; + reference.style.forcedColorAdjust = 'none'; + document.body.append(reference); + + const style = getComputedStyle(element); + const referenceStyle = getComputedStyle(reference); + const result = { + system: { + canvas: referenceStyle.backgroundColor, + canvasText: referenceStyle.color, + highlight: referenceStyle.outlineColor, + }, + trigger: { + backgroundColor: style.backgroundColor, + borderColor: style.borderTopColor, + borderStyle: style.borderTopStyle, + borderWidth: style.borderTopWidth, + outlineColor: style.outlineColor, + outlineStyle: style.outlineStyle, + outlineWidth: style.outlineWidth, + }, + }; + reference.remove(); + return result; + }); + + expect(styles.trigger.backgroundColor).toBe(styles.system.canvas); + expect(styles.trigger.borderColor).toBe(styles.system.canvasText); + expect(styles.trigger.borderColor).not.toBe(styles.trigger.backgroundColor); + expect(styles.trigger.borderStyle).not.toBe('none'); + expect(Number.parseFloat(styles.trigger.borderWidth)).toBeGreaterThan(0); + expect(styles.trigger.outlineColor).toBe(styles.system.highlight); + expect(styles.trigger.outlineStyle).not.toBe('none'); + expect(Number.parseFloat(styles.trigger.outlineWidth)).toBeGreaterThan(0); + }); + + test('removes the tooltip transition when reduced motion is requested', async ({ + page, + }) => { + await page.emulateMedia({ reducedMotion: 'reduce' }); + await page.setViewportSize({ width: 1024, height: 900 }); + await page.goto(ARTICLE); + await expectWorkspaceReady(page); + + const trigger = page.getByRole('button', { name: 'Page actions' }); + await trigger.focus(); + const tooltip = page.getByRole('tooltip', { name: 'Page actions' }); + await expect(tooltip).toBeVisible(); + expect( + await tooltip.evaluate((element) => getComputedStyle(element).transitionDuration), + ).toBe('0s'); + }); +}); diff --git a/apps/website/src/components/docs/DocsControlPlane.spec.tsx b/apps/website/src/components/docs/DocsControlPlane.spec.tsx index c929b4659..0372b279b 100644 --- a/apps/website/src/components/docs/DocsControlPlane.spec.tsx +++ b/apps/website/src/components/docs/DocsControlPlane.spec.tsx @@ -12,6 +12,7 @@ import { import { beforeEach, describe, expect, it, vi } from 'vitest'; import { DocsControlPlane, DocsContextContent } from './DocsControlPlane'; import { docsConfig } from '../../lib/docs-config'; +import { declarationsFor } from '../../styles/style-contract'; const LIBRARY_TITLES = docsConfig .filter((l) => l.group === 'library') @@ -44,6 +45,66 @@ beforeEach(() => { }); describe('DocsControlPlane', () => { + it('keeps context headings on the shared sentence-case sans contract', () => { + for (const selector of [ + '[data-docs-control-plane-context] [data-control-plane-section-trigger]', + '[data-docs-control-plane-context] [data-control-plane-section-heading]', + ]) { + const declarations = declarationsFor(docsCss, selector); + expect(declarations).toMatch(/font-family:\s*var\(--font-inter\)/); + expect(declarations).toMatch(/font-size:\s*12px/); + expect(declarations).toMatch(/font-weight:\s*600/); + expect(declarations).toMatch(/letter-spacing:\s*normal/); + expect(declarations).toMatch(/color:\s*var\(--color-text-muted\)/); + expect(declarations).toMatch(/text-transform:\s*none/); + } + }); + + it('rotates the shared disclosure chevron as a complete icon', () => { + const chevron = declarationsFor( + docsCss, + '[data-docs-control-plane-context] [data-control-plane-section-trigger] [data-control-plane-section-chevron]' + ); + const expanded = declarationsFor( + docsCss, + '[data-docs-control-plane-context] [data-control-plane-section-trigger][aria-expanded="true"] [data-control-plane-section-chevron]' + ); + + expect(chevron).toMatch(/transition:[^;]*transform\s+150ms\s+ease/); + expect(chevron).toMatch(/transform:\s*rotate\(0deg\)/); + expect(expanded).toMatch(/transform:\s*rotate\(90deg\)/); + }); + + it('uses complete rounded sidebar states without a left marker', () => { + for (const selector of [ + '.docs-sidebar-top-link', + '.docs-sidebar-section-link', + ]) { + const declarations = declarationsFor(docsCss, selector); + expect(declarations).toMatch(/border-radius:\s*7px/); + expect(declarations).not.toMatch( + /border-(?:left|inline-start)(?:-(?:color|style|width))?\s*:/ + ); + } + + const hover = declarationsFor( + docsCss, + '[data-docs-navlink]:not([data-active]):hover' + ); + const active = declarationsFor( + docsCss, + '[data-docs-navlink][data-active]' + ); + expect(hover).toMatch(/background:\s*var\(--color-surface-dim\)/); + expect(active).toMatch(/background:\s*var\(--color-accent-surface\)/); + expect(hover).not.toMatch( + /border-(?:left|inline-start)(?:-(?:color|style|width))?\s*:/ + ); + expect(active).not.toMatch( + /border-(?:left|inline-start)(?:-(?:color|style|width))?\s*:/ + ); + }); + it('styles the preview hooks for forced colors and reduced motion', () => { expect(docsCss).toMatch(/\[data-docs-runtime-preview\]/); expect(docsCss).toMatch(/@media \(forced-colors:\s*active\)/); @@ -84,6 +145,27 @@ describe('DocsControlPlane', () => { expect(screen.queryByRole('button', { name: 'Settings' })).toBeNull(); }); + it('renders Lucide rail and action icons with the accepted stroke', () => { + render( + + ); + + const rail = screen.getByRole('navigation', { name: 'Docs modes' }); + const icons = [ + ...rail.querySelectorAll('[data-control-plane-rail-icon] svg.lucide'), + screen.getByRole('button', { name: 'Search docs' }).querySelector('svg'), + ]; + expect(icons).toHaveLength(5); + for (const icon of icons) { + expect(icon?.getAttribute('stroke-width')).toBe('2'); + } + }); + it('shows truthful scope and a collapsed configuration-only Runtime preview', async () => { render( { + it('describes the closed trigger with a tooltip and restores it after Escape', async () => { + await renderActions(); + const trigger = screen.getByRole('button', { name: 'Page actions' }); + const tooltip = screen.getByRole('tooltip'); + + expect(trigger.getAttribute('aria-label')).toBe('Page actions'); + expect(trigger.getAttribute('aria-describedby')).toBe(tooltip.id); + expect(tooltip.textContent).toBe('Page actions'); + expect(trigger.nextElementSibling).toBe(tooltip); + + fireEvent.click(trigger); + expect(trigger.getAttribute('aria-label')).toBe('Page actions'); + expect(trigger.hasAttribute('aria-describedby')).toBe(false); + expect(screen.queryByRole('tooltip')).toBeNull(); + + fireEvent.keyDown(document, { key: 'Escape' }); + await waitFor(() => expect(document.activeElement).toBe(trigger)); + const restoredTooltip = screen.getByRole('tooltip'); + expect(trigger.getAttribute('aria-describedby')).toBe(restoredTooltip.id); + expect(restoredTooltip.textContent).toBe('Page actions'); + }); + + it('dismisses the closed tooltip with Escape without moving trigger focus', async () => { + await renderActions(); + const trigger = screen.getByRole('button', { name: 'Page actions' }); + trigger.focus(); + expect(screen.getByRole('tooltip')).toBeTruthy(); + + fireEvent.keyDown(document, { key: 'Escape' }); + + await waitFor(() => expect(screen.queryByRole('tooltip')).toBeNull()); + expect(document.activeElement).toBe(trigger); + expect(trigger.hasAttribute('aria-describedby')).toBe(false); + }); + + it('keeps a dismissed tooltip closed until a new focus or pointer cycle', async () => { + await renderActions(); + const trigger = screen.getByRole('button', { name: 'Page actions' }); + trigger.focus(); + fireEvent.keyDown(document, { key: 'Escape' }); + await waitFor(() => expect(screen.queryByRole('tooltip')).toBeNull()); + + fireEvent.keyDown(document, { key: 'ArrowDown' }); + expect(screen.queryByRole('tooltip')).toBeNull(); + + fireEvent.blur(trigger); + expect(screen.queryByRole('tooltip')).toBeNull(); + fireEvent.focus(trigger); + await waitFor(() => expect(screen.getByRole('tooltip')).toBeTruthy()); + + fireEvent.keyDown(document, { key: 'Escape' }); + await waitFor(() => expect(screen.queryByRole('tooltip')).toBeNull()); + fireEvent.pointerMove(trigger); + expect(screen.queryByRole('tooltip')).toBeNull(); + fireEvent.pointerLeave(trigger); + expect(screen.queryByRole('tooltip')).toBeNull(); + fireEvent.pointerEnter(trigger); + expect(screen.getByRole('tooltip')).toBeTruthy(); + }); + it('uses one ellipsis trigger and keeps utilities inside its menu', async () => { await renderActions(); expect(screen.getByRole('button', { name: /page actions/i })).toBeTruthy(); diff --git a/apps/website/src/components/docs/PageActions.tsx b/apps/website/src/components/docs/PageActions.tsx index 1e7f3db5c..3246d165b 100644 --- a/apps/website/src/components/docs/PageActions.tsx +++ b/apps/website/src/components/docs/PageActions.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useEffect, useRef, useState, type KeyboardEvent as ReactKeyboardEvent } from 'react'; +import { useEffect, useId, useRef, useState, type KeyboardEvent as ReactKeyboardEvent } from 'react'; import { Bot, Check, @@ -26,7 +26,9 @@ interface Props { } export function PageActions({ library, section, slug, headings }: Props) { + const tooltipId = useId(); const [open, setOpen] = useState(false); + const [tooltipDismissed, setTooltipDismissed] = useState(false); const [copied, setCopied] = useState(false); const [showHeadings, setShowHeadings] = useState(false); const ref = useRef(null); @@ -58,6 +60,15 @@ export function PageActions({ library, section, slug, headings }: Props) { }; }, [open]); + useEffect(() => { + if (open) return undefined; + const onKey = (event: KeyboardEvent) => { + if (event.key === 'Escape') setTooltipDismissed(true); + }; + document.addEventListener('keydown', onKey); + return () => document.removeEventListener('keydown', onKey); + }, [open]); + useEffect( () => () => { if (copiedTimer.current) clearTimeout(copiedTimer.current); @@ -123,8 +134,11 @@ export function PageActions({ library, section, slug, headings }: Props) { type="button" ref={triggerRef} aria-label="Page actions" + aria-describedby={!open && !tooltipDismissed ? tooltipId : undefined} aria-haspopup="menu" aria-expanded={open} + onFocus={() => setTooltipDismissed(false)} + onPointerEnter={() => setTooltipDismissed(false)} onClick={() => { restoreTriggerFocus.current = true; setOpen((current) => !current); @@ -134,6 +148,11 @@ export function PageActions({ library, section, slug, headings }: Props) { >