diff --git a/.github/workflows/repair-pr3-stack-20260831.yml b/.github/workflows/repair-pr3-stack-20260831.yml new file mode 100644 index 0000000..80e4857 --- /dev/null +++ b/.github/workflows/repair-pr3-stack-20260831.yml @@ -0,0 +1,177 @@ +name: Repair PR 3 stack + +on: + push: + branches: + - feat/developer-surface-system + +permissions: + contents: write + +concurrency: + group: repair-pr3-stack + cancel-in-progress: false + +jobs: + repair: + if: github.actor != 'github-actions[bot]' + runs-on: ubuntu-latest + timeout-minutes: 45 + steps: + - name: Check out feature branch + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + ref: feat/developer-surface-system + fetch-depth: 0 + + - name: Set up pnpm + uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4 + with: + version: 10.17.1 + + - name: Set up Node + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: 24 + cache: pnpm + + - name: Set up Chrome for Testing + id: chrome + uses: browser-actions/setup-chrome@2e1d749697dd1612b833dba4a722266286fbefcd # v2.1.2 + with: + chrome-version: stable + + - name: Rebuild PR 3 from the verified PR 2 tree + env: + CHROME_PATH: ${{ steps.chrome.outputs.chrome-path }} + run: | + set -euo pipefail + + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git fetch origin feat/developer-surface-system test/specimen-visual-review + + old_head="$(git rev-parse origin/feat/developer-surface-system)" + + # PR 2 removes its one-shot repair workflow only after full CI and the + # browser/mobile matrix pass. Wait for that proven head rather than + # racing a partially repaired stack. + base_head="" + for _ in $(seq 1 180); do + git fetch origin test/specimen-visual-review + candidate="$(git rev-parse origin/test/specimen-visual-review)" + if ! git cat-file -e "$candidate:.github/workflows/repair-pr2-stack-20260831.yml" 2>/dev/null; then + base_head="$candidate" + break + fi + sleep 5 + done + + if [[ -z "$base_head" ]]; then + echo "Timed out waiting for PR 2's verified repair head." >&2 + exit 1 + fi + + git checkout --detach "$base_head" + + preserve=( + .github/workflows/visual-review.yml + apps/specimens/src/developer-showcase.tsx + apps/specimens/src/main.tsx + docs/developer-surface.md + package.json + packages/ui/src/blocks/developer-surface.tsx + packages/ui/src/components/command-receipt.tsx + packages/ui/src/components/connection-status.tsx + packages/ui/src/components/ui/tabs.tsx + packages/ui/src/index.ts + packages/ui/tests/developer-surface.test.tsx + packages/ui/tests/tabs.test.tsx + registry/developer/registry.fragment.json + scripts/check-registry-clean.mjs + scripts/compose-registry.mjs + scripts/developer-visual-review.mjs + scripts/test-registry-consumer.mjs + scripts/verify-contracts.mjs + vercel.json + ) + + for path in "${preserve[@]}"; do + mkdir -p "$(dirname "$path")" + git show "$old_head:$path" > "$path" + done + + rm -f .github/workflows/repair-pr3-stack-20260831.yml + rm -f .github/workflows/refine-visual-clipping-check.yml + + # Preserve all developer-surface contracts while importing the final + # intrinsic-grid regression guard from the verified shell base. + python - <<'PY' + from pathlib import Path + + path = Path("scripts/verify-contracts.mjs") + text = path.read_text() + marker = ''' [ + "specimen chrome avoids decorative gradients", + !specimenCss.includes("gradient("), + ],''' + assertion = ''' [ + "responsive grids allow intrinsic shrinking", + specimenFixes.includes( + ".specimen-shell {\\n grid-template-columns: minmax(0, 1fr);", + ) && + specimenFixes.includes( + ".catalog-group__grid {\\n grid-template-columns: minmax(0, 1fr);", + ), + ], +''' + if "responsive grids allow intrinsic shrinking" not in text: + if marker not in text: + raise SystemExit("Could not locate contract insertion point") + text = text.replace(marker, assertion + marker, 1) + path.write_text(text) + PY + + pnpm install --frozen-lockfile + pnpm registry:build + pnpm exec prettier --write \ + "${preserve[@]}" \ + registry.json \ + 'public/r/*.json' + + git add -A + tree="$(git write-tree)" + merge_commit="$(printf '%s\n' 'chore(stack): rebuild developer surface on verified visual base' | git commit-tree "$tree" -p "$old_head" -p "$base_head")" + + git checkout --detach "$merge_commit" + pnpm check + + rm -rf artifacts/visual-review artifacts/mobile-quality + mkdir -p artifacts/visual-review artifacts/mobile-quality + preview_log="$RUNNER_TEMP/specimens-preview.log" + pnpm --filter @opencoven/specimens preview --host 127.0.0.1 --port 4173 >"$preview_log" 2>&1 & + preview_pid=$! + cleanup() { + kill "$preview_pid" 2>/dev/null || true + wait "$preview_pid" 2>/dev/null || true + } + trap cleanup EXIT + + ready=false + for _ in $(seq 1 120); do + if curl --fail --silent http://127.0.0.1:4173/ >/dev/null; then + ready=true + break + fi + sleep 0.1 + done + if [[ "$ready" != "true" ]]; then + cat "$preview_log" + exit 1 + fi + + node scripts/visual-review.mjs + node scripts/developer-visual-review.mjs + node scripts/mobile-quality-review.mjs + + git push origin "$merge_commit":refs/heads/feat/developer-surface-system diff --git a/.github/workflows/visual-review.yml b/.github/workflows/visual-review.yml index 3fd9cdd..f12b2ef 100644 --- a/.github/workflows/visual-review.yml +++ b/.github/workflows/visual-review.yml @@ -6,7 +6,10 @@ on: - ".github/workflows/visual-review.yml" - "apps/specimens/**" - "packages/ui/**" + - "registry/**" + - "scripts/compose-registry.mjs" - "scripts/visual-review.mjs" + - "scripts/developer-visual-review.mjs" - "scripts/mobile-quality-review.mjs" workflow_dispatch: @@ -21,7 +24,7 @@ jobs: specimens: name: Specimen browser runs-on: ubuntu-latest - timeout-minutes: 20 + timeout-minutes: 25 steps: - name: Check out repository @@ -43,8 +46,8 @@ jobs: - name: Install dependencies run: pnpm install --frozen-lockfile - - name: Build package and specimen app - run: pnpm build + - name: Build registry, package, and specimen app + run: pnpm registry:build && pnpm build - name: Set up Chrome for Testing id: chrome @@ -89,14 +92,16 @@ jobs: set +e node scripts/visual-review.mjs - visual_status=$? + browser_status=$? + node scripts/developer-visual-review.mjs + developer_status=$? node scripts/mobile-quality-review.mjs mobile_status=$? set -e cp "$preview_log" artifacts/visual-review/preview.log - if [[ "$visual_status" -ne 0 || "$mobile_status" -ne 0 ]]; then + if [[ "$browser_status" -ne 0 || "$developer_status" -ne 0 || "$mobile_status" -ne 0 ]]; then exit 1 fi diff --git a/apps/specimens/src/developer-showcase.tsx b/apps/specimens/src/developer-showcase.tsx new file mode 100644 index 0000000..4812f9e --- /dev/null +++ b/apps/specimens/src/developer-showcase.tsx @@ -0,0 +1,213 @@ +import { DeveloperSurface } from "@opencoven/ui"; +import { ArrowLeft, BookOpen, Boxes } from "lucide-react"; + +function DeveloperShowcase() { + return ( +
+
+
+ + +
+
+ +
+
+
+

+ Shared development UI +

+

+ One visual language for the work around the work. +

+

+ Reusable components for project context, SDK state, CLI execution, + daemon authority, runtime health, and verifiable receipts. The UI + presents canonical state; it never invents authority. +

+
+
+ {[ + ["CLI", "coven"], + ["SDK", "read-only"], + ["Authority", "daemon"], + ].map(([label, value]) => ( +
+
+ {label} +
+
+ {value} +
+
+ ))} +
+
+ +
+ +
+

+ Integration contract +

+

+ Normalize, then render. +

+

+ SDK and CLI adapters map external responses into small, + presentation-safe view models. UI code does not perform + discovery, credential lookup, transport negotiation, or + daemon mutation. +

+
+
+

+ Public install +

+ + pnpm dlx shadcn@latest add + https://ui.opencoven.ai/r/developer-surface.json + +
+
+

+ Canonical CLI +

+ + npm install -g @opencoven/cli + +
+
+ } + /> + + + + ); +} + +export { DeveloperShowcase }; diff --git a/apps/specimens/src/main.tsx b/apps/specimens/src/main.tsx index ba2fdcb..4dbb8f6 100644 --- a/apps/specimens/src/main.tsx +++ b/apps/specimens/src/main.tsx @@ -5,8 +5,24 @@ import "@opencoven/ui/globals.css"; import "./specimens.css"; import "./specimens-fixes.css"; import { App } from "./app"; +import { DeveloperShowcase } from "./developer-showcase"; + +const root = document.getElementById("root"); + +if (!root) { + throw new Error("Specimen root is missing"); +} const normalizedPath = window.location.pathname.replace(/\/+$/, "") || "/"; +const storedScheme = + localStorage.getItem("coven-ui:scheme") === "light" ? "light" : "dark"; +const storedDensity = + localStorage.getItem("coven-ui:density") === "compact" + ? "compact" + : "default"; + +document.documentElement.classList.toggle("dark", storedScheme === "dark"); +document.documentElement.dataset.density = storedDensity; if (normalizedPath !== "/") { window.addEventListener( @@ -20,14 +36,8 @@ if (normalizedPath !== "/") { ); } -const root = document.getElementById("root"); - -if (!root) { - throw new Error("Specimen root is missing"); -} - createRoot(root).render( - + {normalizedPath === "/developer" ? : } , ); diff --git a/docs/developer-surface.md b/docs/developer-surface.md new file mode 100644 index 0000000..1ee3ad5 --- /dev/null +++ b/docs/developer-surface.md @@ -0,0 +1,181 @@ +# Developer surface integration contract + +OpenCoven UI exposes presentation primitives for development tooling. It does +not own runtime discovery, credentials, authority, orchestration, session +mutation, execution, or receipt provenance. + +## Canonical ownership + +| Concern | Canonical producer | UI responsibility | +| ------------------------------- | -------------------------------- | -------------------------------------------------------------------------------------------------------- | +| Cave/Coven read models | `@opencoven/sdk` packages | Normalize returned state into bounded view models and render it | +| User-facing local CLI | `@opencoven/cli` (`coven`) | Render presentation-safe operation labels, state, and receipts supplied by the host | +| Session/runtime authority | Coven daemon (`coven.daemon.v1`) | Display authority state explicitly; never infer permission | +| Orchestration authority | Psyche | Display task, lease, approval, and receipt state supplied by the client | +| Protected mutation | Threads + Coven | Present pending, proposed, committed, denied, or recovery-required state without performing the mutation | +| Production application behavior | Cave | Consume these components where appropriate; Cave remains product authority | + +## Current SDK status + +The TypeScript SDK repository is experimental and not ready for public +production consumption. Its source packages are currently private and not +published. The intended first public release is deliberately read-only: +discovery, compatibility, pairing, health, and canonical reads are in scope; +message sending, streaming, attachments, task handoffs, GitHub mutation, and +offline mutation queues are deferred. + +Do not make a UI control look executable merely because an SDK type exists. Use +`ConnectionStatus` authority labels and blocked or proposal states until a +canonical producer supplies explicit authority. + +## Current CLI status + +The canonical user CLI is `@opencoven/cli`, invoked as `coven`. The package is +live, while the SDK repository's `@opencoven/dev-cli` remains a private +repository-development workspace and must not be presented as the public CLI. + +```bash +npm install -g @opencoven/cli +coven doctor +``` + +The Coven daemon remains the authority boundary. The CLI is a client and must +not be treated as proof that an operation was authorized or completed. + +## View-model contract + +`DeveloperSurface` accepts stable, host-owned identifiers so React keys do not +depend on labels, commands, or array positions: + +```ts +import type { DeveloperConnection, DeveloperReceipt } from "@opencoven/ui"; + +const connections: DeveloperConnection[] = [ + { + id: "coven-daemon-primary", + name: "Coven daemon", + kind: "Daemon", + state: "connected", + authority: "local-authority", + meta: "coven.daemon.v1", + }, +]; + +const receipts: DeveloperReceipt[] = [ + { + id: "doctor-latest", + channel: "cli", + displayCommand: "coven doctor", + status: "succeeded", + receiptId: "receipt:doctor:01", + exitCode: 0, + }, +]; +``` + +The item `id` is presentation identity used by the component tree. `receiptId` +is optional evidence identity supplied by the canonical producer. They are not +interchangeable, and neither grants authority. + +## Protected-data contract + +`CommandReceipt.displayCommand` is intentionally named as display data. It must +already be bounded and redacted before it reaches the UI package. + +Never pass any of the following as `displayCommand`, `summary`, `receiptId`, +`meta`, or another visible field without a separately reviewed disclosure +contract: + +- raw process arguments or shell command lines; +- prompts, message bodies, terminal output, source code, or repository content; +- bearer tokens, invite material, credentials, cookies, private keys, or + certificates; +- environment maps, infrastructure URLs, or unredacted user paths; +- arbitrary provider responses or error payloads. + +A display label such as `session.create · project=[redacted]` is acceptable only +when the host intentionally produced that safe projection. Renaming raw data to +`displayCommand` does not sanitize it. + +## Receipt-state contract + +The component renders, but does not derive, these host-supplied states: + +| State | Meaning | +| ------------------- | ------------------------------------------------------------------------ | +| `accepted` | The canonical producer accepted the request; execution is not yet proved | +| `running` | The canonical producer reports active execution | +| `succeeded` | A terminal success was reported | +| `failed` | A terminal failure was reported | +| `blocked` | Policy, capability, approval, or validation prevented execution | +| `unknown` | The effect or terminal state cannot currently be proved | +| `recovery-required` | Operator or producer reconciliation is required before retry | + +Do not translate PTY creation, transport connection, local UI transition, or +process spawn into `accepted` or `succeeded` unless the owning protocol defines +that event as authoritative. + +## Adapter pattern + +Keep integration code outside the UI package: + +```ts +import type { DeveloperConnection, DeveloperReceipt } from "@opencoven/ui"; + +export function toCaveConnection(result: CaveHealth): DeveloperConnection { + return { + id: `cave:${result.instanceId}`, + name: "Cave client", + kind: "SDK", + state: result.ok ? "connected" : "degraded", + authority: "read-only", + version: result.protocolVersion, + meta: result.instanceId, + }; +} + +export function toCliReceipt(result: CovenCommandResult): DeveloperReceipt { + return { + id: result.uiRecordId, + channel: "cli", + displayCommand: result.redactedDisplayCommand, + status: result.receiptStatus, + receiptId: result.receiptId, + exitCode: result.exitCode, + duration: result.duration, + timestamp: result.timestamp, + }; +} +``` + +Then render the normalized values: + +```tsx +import { DeveloperSurface } from "@opencoven/ui"; + +; +``` + +Use `headingLevel={3}` when embedding the block below an existing level-two +section. Each rendered instance generates unique heading relationships, so +multiple surfaces may coexist without duplicate DOM IDs. + +The UI package performs no import-time I/O and does not depend on +`@opencoven/sdk` or `@opencoven/cli`. This prevents dependency cycles, keeps +package consumption lightweight, and preserves the security boundary: adapters +retrieve, validate, bound, and redact state; components present it. + +## Registry installation + +```bash +pnpm dlx shadcn@latest add https://ui.opencoven.ai/r/connection-status.json +pnpm dlx shadcn@latest add https://ui.opencoven.ai/r/command-receipt.json +pnpm dlx shadcn@latest add https://ui.opencoven.ai/r/developer-surface.json +``` + +The assembled reference is available at `/developer` in the specimen app. diff --git a/handoffs/visual-review.md b/handoffs/visual-review.md index 42a31b0..ab78835 100644 --- a/handoffs/visual-review.md +++ b/handoffs/visual-review.md @@ -4,29 +4,29 @@ The specimen browser produces reviewable viewport receipts for changes that can alter its presentation. These checks are render and responsive-contract smoke tests, not a pixel-perfect golden-image suite. -## What the workflow proves +## Library and assembled-browser receipts -For each run, `scripts/visual-review.mjs` drives Chrome through the DevTools -Protocol without adding a browser-testing dependency to the package graph. It -checks: +`scripts/visual-review.mjs` drives Chrome through the DevTools Protocol without +adding a browser-testing dependency to the package graph. It checks: - the top bar, responsive rail, and main landmark are visible; -- the page has no horizontal overflow and key assembled surfaces have no hidden internal clipping; +- the page has no horizontal overflow and key assembled surfaces have no hidden + internal clipping; - light/dark scheme and cozy/compact density persist through reload; - the library renders all 16 specimens in its three task groups; - the assembled lab renders five tabs; - no uncaught exception or `console.error` is emitted. -The visual runner captures these viewport receipts: - | Surface | Viewport | Scheme | Density | -|---|---:|---|---| +| --- | ---: | --- | --- | | Library | 1440×1000 | dark | cozy | | Library | 390×844 | dark | cozy | | Library | 1440×1000 | light | compact | | Assembled lab | 1440×1000 | dark | cozy | | Assembled lab | 390×844 | dark | compact | +## Mobile quality receipts + `scripts/mobile-quality-review.mjs` adds a stricter library-surface matrix. It checks the 16-card catalog at 320, 375, 390, and 430 px; light and dark schemes; cozy and compact density; RTL direction; reduced-motion behavior; and a 200% @@ -41,16 +41,38 @@ Its purpose is to catch rem-scaled viewport floors, fixed-size controls, and other layout assumptions that make enlarged text force page-level horizontal scrolling. -Every run uploads both receipt sets as PNGs with `summary.json` and Markdown +## Developer-surface receipts + +`scripts/developer-visual-review.mjs` checks the isolated `/developer` reference +surface at desktop and mobile sizes, in dark and light schemes, compact and cozy +density, and at 200% root text sizing. It verifies: + +- the expected four integration cards and three invocation receipts render; +- authority and receipt text is present rather than encoded by color alone; +- the known no-op showcase controls remain absent; +- scheme, density, and reduced-motion settings are active; +- heading relationships contain no duplicate DOM IDs; +- the page and each developer card remain free of horizontal/internal clipping; +- no uncaught exception or `console.error` is emitted. + +| Surface | Viewport | Scheme | Density | Text scale | +| --- | ---: | --- | --- | ---: | +| Developer surface | 1440×1000 | dark | cozy | 100% | +| Developer surface | 390×844 | dark | compact | 100% | +| Developer surface | 1440×1000 | light | compact | 100% | +| Developer surface | 390×900 | dark | cozy | 200% | + +Every run uploads both receipt sets as PNGs with machine-readable and Markdown summaries. The visual artifact also includes the Vite preview log, and each runner writes a bounded Chrome log when its capture process fails. Artifacts are retained for 14 days. ## Local use -Build and start the specimen preview first: +Build the registry, package, and specimen preview first: ```bash +pnpm registry:build pnpm build pnpm --filter @opencoven/specimens preview --host 127.0.0.1 --port 4173 ``` @@ -59,19 +81,20 @@ Then, from another shell: ```bash CHROME_PATH=/path/to/chrome node scripts/visual-review.mjs +CHROME_PATH=/path/to/chrome node scripts/developer-visual-review.mjs CHROME_PATH=/path/to/chrome node scripts/mobile-quality-review.mjs ``` Set `BASE_URL` when the preview is not on `http://127.0.0.1:4173`. Set `VISUAL_OUTPUT_DIR` or `MOBILE_OUTPUT_DIR` to change the corresponding receipt -directory. `CHROME_DEBUGGING_PORT` and `MOBILE_CHROME_PORT` may be overridden -when the default local ports are occupied. +directory. `CHROME_DEBUGGING_PORT`, `DEVELOPER_CHROME_PORT`, and +`MOBILE_CHROME_PORT` may be overridden when the default local ports are occupied. ## Review policy -A green result proves the shell rendered, stayed within the requested viewport, -kept key surfaces free of hidden internal clipping, preserved the named -structural contracts, and emitted no observed runtime error. It does not prove -subjective visual quality. Reviewers should still open the PNG receipts when -hierarchy, spacing, typography, responsive behavior, or component composition -changed. +A green result proves the named surface rendered, stayed within the requested +viewport, preserved the asserted structural and accessibility relationships, +and emitted no observed runtime error. It does not prove subjective visual +quality or product-level authority behavior. Reviewers should still open the +PNG receipts whenever hierarchy, spacing, typography, responsive behavior, +component composition, or state semantics changed. diff --git a/package.json b/package.json index 5e93604..cae5f03 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,7 @@ "registry:compose": "node scripts/compose-registry.mjs", "registry:validate": "pnpm registry:compose && shadcn registry validate registry.json", "registry:build": "pnpm registry:compose && shadcn build registry.json && node scripts/normalize-registry-aliases.mjs", - "registry:check": "pnpm registry:build && git diff --exit-code -- public/r", + "registry:check": "pnpm registry:build && node scripts/check-registry-clean.mjs", "test:consumer": "node scripts/test-registry-consumer.mjs", "package:check": "node scripts/check-package-exports.mjs", "deploy:check": "node scripts/verify-deploy-output.mjs", diff --git a/packages/ui/src/blocks/developer-surface.tsx b/packages/ui/src/blocks/developer-surface.tsx new file mode 100644 index 0000000..0d02404 --- /dev/null +++ b/packages/ui/src/blocks/developer-surface.tsx @@ -0,0 +1,208 @@ +import { Code2, GitBranch, Layers3 } from "lucide-react"; +import { useId, type ReactNode } from "react"; + +import { + CommandReceipt, + type CommandReceiptProps, +} from "@opencoven/ui/components/command-receipt"; +import { + ConnectionStatus, + type ConnectionStatusProps, +} from "@opencoven/ui/components/connection-status"; +import { cn } from "@opencoven/ui/lib/utils"; + +type DeveloperConnection = ConnectionStatusProps & { id: string }; +type DeveloperReceipt = CommandReceiptProps & { id: string }; + +type DeveloperSurfaceProps = { + project: string; + branch?: string; + title?: string; + description?: string; + connections: readonly DeveloperConnection[]; + activity?: readonly DeveloperReceipt[]; + actions?: ReactNode; + aside?: ReactNode; + headingLevel?: 2 | 3; + className?: string; +}; + +function DeveloperSurface({ + project, + branch, + title = "Development surface", + description = "Project context, integration health, and execution evidence in one reusable surface.", + connections, + activity = [], + actions, + aside, + headingLevel = 2, + className, +}: DeveloperSurfaceProps) { + const id = useId(); + const titleId = `${id}-title`; + const connectionsId = `${id}-connections`; + const activityId = `${id}-activity`; + const Title = headingLevel === 2 ? "h2" : "h3"; + const SectionTitle = headingLevel === 2 ? "h3" : "h4"; + + return ( +
+
+
+ + + + {title} + +

+ {description} +

+
+ {actions ? ( +
+ {actions} +
+ ) : null} +
+ +
+
+
+ + + {branch ? ( + + + ) : null} +
+ +
+
+
+

+ Integrations +

+ + Connected development context + +
+ + {connections.length} source{connections.length === 1 ? "" : "s"} + +
+ {connections.length > 0 ? ( +
+ {connections.map(({ id: connectionId, ...connection }) => ( + + ))} +
+ ) : ( +

+ No integration state is available. Do not infer connectivity or + authority from an empty response. +

+ )} +
+ +
+
+
+

+ Evidence +

+ + Recent invocations + +
+ + {activity.length} receipt{activity.length === 1 ? "" : "s"} + +
+ {activity.length > 0 ? ( +
+ {activity.map(({ id: receiptId, ...receipt }) => ( + + ))} +
+ ) : ( +

+ No invocation receipts yet. Keep empty states explicit rather + than fabricating execution history. +

+ )} +
+
+ + +
+
+ ); +} + +export { + DeveloperSurface, + type DeveloperConnection, + type DeveloperReceipt, + type DeveloperSurfaceProps, +}; diff --git a/packages/ui/src/components/command-receipt.tsx b/packages/ui/src/components/command-receipt.tsx new file mode 100644 index 0000000..4333151 --- /dev/null +++ b/packages/ui/src/components/command-receipt.tsx @@ -0,0 +1,169 @@ +import { + Ban, + CheckCircle2, + CircleDashed, + Clock3, + TerminalSquare, + TriangleAlert, +} from "lucide-react"; + +import { cn } from "@opencoven/ui/lib/utils"; + +type InvocationChannel = "cli" | "sdk" | "daemon" | "runtime"; +type InvocationStatus = + | "accepted" + | "running" + | "succeeded" + | "failed" + | "blocked" + | "unknown" + | "recovery-required"; + +type CommandReceiptProps = { + channel: InvocationChannel; + /** A presentation-safe, pre-redacted command or operation label. */ + displayCommand: string; + status: InvocationStatus; + /** A non-secret receipt or evidence reference supplied by the canonical producer. */ + receiptId?: string; + summary?: string; + duration?: string; + exitCode?: number; + /** An ISO 8601 timestamp supplied by the host, when available. */ + timestamp?: string; + className?: string; +}; + +const statusDetails: Record< + InvocationStatus, + { label: string; icon: typeof CheckCircle2; className: string } +> = { + accepted: { + label: "Accepted", + icon: CheckCircle2, + className: "text-information", + }, + running: { + label: "Running", + icon: CircleDashed, + className: "text-information", + }, + succeeded: { + label: "Succeeded", + icon: CheckCircle2, + className: "text-success", + }, + failed: { + label: "Failed", + icon: TriangleAlert, + className: "text-destructive", + }, + blocked: { + label: "Blocked", + icon: Ban, + className: "text-warning", + }, + unknown: { + label: "Unknown", + icon: Clock3, + className: "text-warning", + }, + "recovery-required": { + label: "Recovery required", + icon: TriangleAlert, + className: "text-warning", + }, +}; + +function CommandReceipt({ + channel, + displayCommand, + status, + receiptId, + summary, + duration, + exitCode, + timestamp, + className, +}: CommandReceiptProps) { + const statusDetail = statusDetails[status]; + const StatusIcon = statusDetail.icon; + + return ( +
+
+ + + + + + {channel} + + + + + + {displayCommand} + + {summary ? ( +

+ {summary} +

+ ) : null} +
+
+ + {receiptId || duration || exitCode !== undefined || timestamp ? ( +
+ {receiptId ? ( + + receipt {receiptId} + + ) : null} + {duration ? ( + + + ) : null} + {exitCode !== undefined ? exit {exitCode} : null} + {timestamp ? ( + + ) : null} +
+ ) : null} +
+ ); +} + +export { + CommandReceipt, + type CommandReceiptProps, + type InvocationChannel, + type InvocationStatus, +}; diff --git a/packages/ui/src/components/connection-status.tsx b/packages/ui/src/components/connection-status.tsx new file mode 100644 index 0000000..469230e --- /dev/null +++ b/packages/ui/src/components/connection-status.tsx @@ -0,0 +1,173 @@ +import { + CheckCircle2, + CircleDashed, + CircleOff, + ShieldCheck, + ShieldQuestion, + TriangleAlert, +} from "lucide-react"; +import type { ReactNode } from "react"; + +import { cn } from "@opencoven/ui/lib/utils"; + +type ConnectionState = + "connected" | "pending" | "degraded" | "disconnected" | "unavailable"; + +type AuthorityLevel = "read-only" | "proposal" | "mutating" | "local-authority"; + +type ConnectionStatusProps = { + name: string; + kind: "SDK" | "CLI" | "Daemon" | "Runtime" | "Project"; + state: ConnectionState; + authority: AuthorityLevel; + detail?: string; + version?: string; + meta?: string; + action?: ReactNode; + className?: string; +}; + +const stateDetails: Record< + ConnectionState, + { label: string; icon: typeof CheckCircle2; className: string } +> = { + connected: { + label: "Connected", + icon: CheckCircle2, + className: "text-success", + }, + pending: { + label: "Pending", + icon: CircleDashed, + className: "text-information", + }, + degraded: { + label: "Degraded", + icon: TriangleAlert, + className: "text-warning", + }, + disconnected: { + label: "Disconnected", + icon: CircleOff, + className: "text-muted-foreground", + }, + unavailable: { + label: "Unavailable", + icon: TriangleAlert, + className: "text-destructive", + }, +}; + +const authorityDetails: Record< + AuthorityLevel, + { label: string; icon: typeof ShieldCheck; className: string } +> = { + "read-only": { + label: "Read only", + icon: ShieldCheck, + className: "text-information", + }, + proposal: { + label: "Proposal only", + icon: ShieldQuestion, + className: "text-warning", + }, + mutating: { + label: "Mutation capable", + icon: ShieldQuestion, + className: "text-destructive", + }, + "local-authority": { + label: "Local authority", + icon: ShieldCheck, + className: "text-success", + }, +}; + +function ConnectionStatus({ + name, + kind, + state, + authority, + detail, + version, + meta, + action, + className, +}: ConnectionStatusProps) { + const stateDetail = stateDetails[state]; + const authorityDetail = authorityDetails[authority]; + const StateIcon = stateDetail.icon; + const AuthorityIcon = authorityDetail.icon; + + return ( +
+
+ + + {kind} + + + {name} + + + {version ? ( + + {version} + + ) : null} +
+ + {detail ? ( +

+ {detail} +

+ ) : null} + +
+ + + + + {meta ? ( + + {meta} + + ) : null} + {action ? ( + {action} + ) : null} +
+
+ ); +} + +export { + ConnectionStatus, + type AuthorityLevel, + type ConnectionState, + type ConnectionStatusProps, +}; diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index fc3053d..e11b8d1 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -1,4 +1,5 @@ export * from "./blocks/composer"; +export * from "./blocks/developer-surface"; export * from "./blocks/run-rail"; export * from "./blocks/session-header"; export * from "./blocks/transcript-turn"; @@ -6,7 +7,9 @@ export * from "./blocks/transcript-turn"; export * from "./components/activity-item"; export * from "./components/attachment-chip"; export * from "./components/budget-pill"; +export * from "./components/command-receipt"; export * from "./components/completion-palette"; +export * from "./components/connection-status"; export * from "./components/context-meter"; export * from "./components/empty-state"; export * from "./components/error-state"; diff --git a/packages/ui/tests/developer-surface.test.tsx b/packages/ui/tests/developer-surface.test.tsx new file mode 100644 index 0000000..06d96cd --- /dev/null +++ b/packages/ui/tests/developer-surface.test.tsx @@ -0,0 +1,140 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { axe } from "vitest-axe"; + +import { + CommandReceipt, + ConnectionStatus, + DeveloperSurface, +} from "@opencoven/ui"; + +const daemonConnection = { + id: "daemon-primary", + name: "Coven daemon", + kind: "Daemon" as const, + state: "connected" as const, + authority: "local-authority" as const, + meta: "coven.daemon.v1", +}; + +const successfulReceipt = { + id: "receipt-doctor", + channel: "cli" as const, + displayCommand: "coven doctor", + status: "succeeded" as const, + receiptId: "rcpt-doctor-1", + exitCode: 0, +}; + +describe("developer surface components", () => { + it("renders connection state and authority as text, not color alone", () => { + render( + , + ); + + expect( + screen.getByRole("article", { + name: "SDK Cave client: Connected; Read only", + }), + ).toBeInTheDocument(); + expect(screen.getByText("Connected")).toBeInTheDocument(); + expect(screen.getByText("Read only")).toBeInTheDocument(); + expect(screen.getByText("Cave client")).toBeInTheDocument(); + }); + + it("keeps presentation-safe invocation evidence explicit", () => { + render( + , + ); + + expect( + screen.getByRole("article", { name: "CLI invocation: Succeeded" }), + ).toBeInTheDocument(); + expect(screen.getByText("Succeeded")).toBeInTheDocument(); + expect(screen.getByText("coven doctor")).toBeInTheDocument(); + expect(screen.getByText("receipt rcpt-doctor-1")).toBeInTheDocument(); + expect(screen.getByText("exit 0")).toBeInTheDocument(); + }); + + it("assembles integrations and receipts without performing authority work", async () => { + const { container } = render( + , + ); + + expect(screen.getByText("OpenCoven/ui")).toBeInTheDocument(); + expect(screen.getByText("2 sources")).toBeInTheDocument(); + expect(screen.getByText("1 receipt")).toBeInTheDocument(); + expect(screen.getByText("Authority rule")).toBeInTheDocument(); + + const results = await axe(container); + expect(results.violations).toHaveLength(0); + }); + + it("uses unique heading relationships for repeated surfaces", () => { + const { container } = render( + <> + + + , + ); + + expect( + screen.getByRole("heading", { level: 2, name: "Primary project" }), + ).toBeInTheDocument(); + expect( + screen.getByRole("heading", { level: 3, name: "Secondary project" }), + ).toBeInTheDocument(); + expect( + screen.getByText( + "No integration state is available. Do not infer connectivity or authority from an empty response.", + ), + ).toBeInTheDocument(); + + const ids = [...container.querySelectorAll("[id]")].map( + (element) => element.id, + ); + expect(ids.length).toBeGreaterThan(0); + expect(new Set(ids).size).toBe(ids.length); + }); +}); diff --git a/packages/ui/tsconfig.json b/packages/ui/tsconfig.json index 102e02b..b3ac73c 100644 --- a/packages/ui/tsconfig.json +++ b/packages/ui/tsconfig.json @@ -2,6 +2,7 @@ "extends": "../../tsconfig.json", "compilerOptions": { "paths": { + "@opencoven/ui": ["./src/index.ts"], "@opencoven/ui/*": ["./src/*"] }, "types": ["vitest/globals", "@testing-library/jest-dom"] diff --git a/public/r/command-receipt.json b/public/r/command-receipt.json new file mode 100644 index 0000000..88ac107 --- /dev/null +++ b/public/r/command-receipt.json @@ -0,0 +1,41 @@ +{ + "$schema": "https://ui.shadcn.com/schema/registry-item.json", + "name": "command-receipt", + "title": "Command receipt", + "description": "Presentation-safe CLI, SDK, daemon, or runtime invocation evidence with explicit lifecycle state.", + "dependencies": [ + "lucide-react" + ], + "registryDependencies": [ + "@opencoven/cn", + "@opencoven/coven-theme" + ], + "files": [ + { + "path": "packages/ui/src/components/command-receipt.tsx", + "content": "import {\n Ban,\n CheckCircle2,\n CircleDashed,\n Clock3,\n TerminalSquare,\n TriangleAlert,\n} from \"lucide-react\";\n\nimport { cn } from \"@/lib/utils\";\n\ntype InvocationChannel = \"cli\" | \"sdk\" | \"daemon\" | \"runtime\";\ntype InvocationStatus =\n | \"accepted\"\n | \"running\"\n | \"succeeded\"\n | \"failed\"\n | \"blocked\"\n | \"unknown\"\n | \"recovery-required\";\n\ntype CommandReceiptProps = {\n channel: InvocationChannel;\n /** A presentation-safe, pre-redacted command or operation label. */\n displayCommand: string;\n status: InvocationStatus;\n /** A non-secret receipt or evidence reference supplied by the canonical producer. */\n receiptId?: string;\n summary?: string;\n duration?: string;\n exitCode?: number;\n /** An ISO 8601 timestamp supplied by the host, when available. */\n timestamp?: string;\n className?: string;\n};\n\nconst statusDetails: Record<\n InvocationStatus,\n { label: string; icon: typeof CheckCircle2; className: string }\n> = {\n accepted: {\n label: \"Accepted\",\n icon: CheckCircle2,\n className: \"text-information\",\n },\n running: {\n label: \"Running\",\n icon: CircleDashed,\n className: \"text-information\",\n },\n succeeded: {\n label: \"Succeeded\",\n icon: CheckCircle2,\n className: \"text-success\",\n },\n failed: {\n label: \"Failed\",\n icon: TriangleAlert,\n className: \"text-destructive\",\n },\n blocked: {\n label: \"Blocked\",\n icon: Ban,\n className: \"text-warning\",\n },\n unknown: {\n label: \"Unknown\",\n icon: Clock3,\n className: \"text-warning\",\n },\n \"recovery-required\": {\n label: \"Recovery required\",\n icon: TriangleAlert,\n className: \"text-warning\",\n },\n};\n\nfunction CommandReceipt({\n channel,\n displayCommand,\n status,\n receiptId,\n summary,\n duration,\n exitCode,\n timestamp,\n className,\n}: CommandReceiptProps) {\n const statusDetail = statusDetails[status];\n const StatusIcon = statusDetail.icon;\n\n return (\n \n
\n \n \n \n \n \n \n {channel}\n \n \n \n {statusDetail.label}\n \n \n \n {displayCommand}\n \n {summary ? (\n

\n {summary}\n

\n ) : null}\n \n
\n\n {receiptId || duration || exitCode !== undefined || timestamp ? (\n
\n {receiptId ? (\n \n receipt {receiptId}\n \n ) : null}\n {duration ? (\n \n \n {duration}\n \n ) : null}\n {exitCode !== undefined ? exit {exitCode} : null}\n {timestamp ? (\n \n {timestamp}\n \n ) : null}\n
\n ) : null}\n \n );\n}\n\nexport {\n CommandReceipt,\n type CommandReceiptProps,\n type InvocationChannel,\n type InvocationStatus,\n};\n", + "type": "registry:component", + "target": "@components/command-receipt.tsx" + } + ], + "meta": { + "channels": [ + "cli", + "sdk", + "daemon", + "runtime" + ], + "states": [ + "accepted", + "running", + "succeeded", + "failed", + "blocked", + "unknown", + "recovery-required" + ], + "commandField": "displayCommand", + "protectedData": "pre-redacted-and-bounded" + }, + "type": "registry:component" +} diff --git a/public/r/connection-status.json b/public/r/connection-status.json new file mode 100644 index 0000000..05fcd2f --- /dev/null +++ b/public/r/connection-status.json @@ -0,0 +1,38 @@ +{ + "$schema": "https://ui.shadcn.com/schema/registry-item.json", + "name": "connection-status", + "title": "Connection status", + "description": "Integration health and host-supplied authority state for SDK, CLI, daemon, runtime, and project sources.", + "dependencies": [ + "lucide-react" + ], + "registryDependencies": [ + "@opencoven/cn", + "@opencoven/coven-theme" + ], + "files": [ + { + "path": "packages/ui/src/components/connection-status.tsx", + "content": "import {\n CheckCircle2,\n CircleDashed,\n CircleOff,\n ShieldCheck,\n ShieldQuestion,\n TriangleAlert,\n} from \"lucide-react\";\nimport type { ReactNode } from \"react\";\n\nimport { cn } from \"@/lib/utils\";\n\ntype ConnectionState =\n \"connected\" | \"pending\" | \"degraded\" | \"disconnected\" | \"unavailable\";\n\ntype AuthorityLevel = \"read-only\" | \"proposal\" | \"mutating\" | \"local-authority\";\n\ntype ConnectionStatusProps = {\n name: string;\n kind: \"SDK\" | \"CLI\" | \"Daemon\" | \"Runtime\" | \"Project\";\n state: ConnectionState;\n authority: AuthorityLevel;\n detail?: string;\n version?: string;\n meta?: string;\n action?: ReactNode;\n className?: string;\n};\n\nconst stateDetails: Record<\n ConnectionState,\n { label: string; icon: typeof CheckCircle2; className: string }\n> = {\n connected: {\n label: \"Connected\",\n icon: CheckCircle2,\n className: \"text-success\",\n },\n pending: {\n label: \"Pending\",\n icon: CircleDashed,\n className: \"text-information\",\n },\n degraded: {\n label: \"Degraded\",\n icon: TriangleAlert,\n className: \"text-warning\",\n },\n disconnected: {\n label: \"Disconnected\",\n icon: CircleOff,\n className: \"text-muted-foreground\",\n },\n unavailable: {\n label: \"Unavailable\",\n icon: TriangleAlert,\n className: \"text-destructive\",\n },\n};\n\nconst authorityDetails: Record<\n AuthorityLevel,\n { label: string; icon: typeof ShieldCheck; className: string }\n> = {\n \"read-only\": {\n label: \"Read only\",\n icon: ShieldCheck,\n className: \"text-information\",\n },\n proposal: {\n label: \"Proposal only\",\n icon: ShieldQuestion,\n className: \"text-warning\",\n },\n mutating: {\n label: \"Mutation capable\",\n icon: ShieldQuestion,\n className: \"text-destructive\",\n },\n \"local-authority\": {\n label: \"Local authority\",\n icon: ShieldCheck,\n className: \"text-success\",\n },\n};\n\nfunction ConnectionStatus({\n name,\n kind,\n state,\n authority,\n detail,\n version,\n meta,\n action,\n className,\n}: ConnectionStatusProps) {\n const stateDetail = stateDetails[state];\n const authorityDetail = authorityDetails[authority];\n const StateIcon = stateDetail.icon;\n const AuthorityIcon = authorityDetail.icon;\n\n return (\n \n
\n \n \n {kind}\n \n \n {name}\n \n \n {version ? (\n \n {version}\n \n ) : null}\n
\n\n {detail ? (\n

\n {detail}\n

\n ) : null}\n\n
\n \n \n {stateDetail.label}\n \n \n \n {authorityDetail.label}\n \n {meta ? (\n \n {meta}\n \n ) : null}\n {action ? (\n {action}\n ) : null}\n
\n \n );\n}\n\nexport {\n ConnectionStatus,\n type AuthorityLevel,\n type ConnectionState,\n type ConnectionStatusProps,\n};\n", + "type": "registry:component", + "target": "@components/connection-status.tsx" + } + ], + "meta": { + "states": [ + "connected", + "pending", + "degraded", + "disconnected", + "unavailable" + ], + "authority": [ + "read-only", + "proposal", + "mutating", + "local-authority" + ], + "authoritySource": "host-supplied" + }, + "type": "registry:component" +} diff --git a/public/r/developer-surface.json b/public/r/developer-surface.json new file mode 100644 index 0000000..ecc1ca9 --- /dev/null +++ b/public/r/developer-surface.json @@ -0,0 +1,34 @@ +{ + "$schema": "https://ui.shadcn.com/schema/registry-item.json", + "name": "developer-surface", + "title": "Developer surface", + "description": "Project context, integration health, authority cues, and invocation evidence in one reusable development cockpit.", + "dependencies": [ + "lucide-react" + ], + "registryDependencies": [ + "@opencoven/connection-status", + "@opencoven/command-receipt", + "@opencoven/cn" + ], + "files": [ + { + "path": "packages/ui/src/blocks/developer-surface.tsx", + "content": "import { Code2, GitBranch, Layers3 } from \"lucide-react\";\nimport { useId, type ReactNode } from \"react\";\n\nimport {\n CommandReceipt,\n type CommandReceiptProps,\n} from \"@/components/command-receipt\";\nimport {\n ConnectionStatus,\n type ConnectionStatusProps,\n} from \"@/components/connection-status\";\nimport { cn } from \"@/lib/utils\";\n\ntype DeveloperConnection = ConnectionStatusProps & { id: string };\ntype DeveloperReceipt = CommandReceiptProps & { id: string };\n\ntype DeveloperSurfaceProps = {\n project: string;\n branch?: string;\n title?: string;\n description?: string;\n connections: readonly DeveloperConnection[];\n activity?: readonly DeveloperReceipt[];\n actions?: ReactNode;\n aside?: ReactNode;\n headingLevel?: 2 | 3;\n className?: string;\n};\n\nfunction DeveloperSurface({\n project,\n branch,\n title = \"Development surface\",\n description = \"Project context, integration health, and execution evidence in one reusable surface.\",\n connections,\n activity = [],\n actions,\n aside,\n headingLevel = 2,\n className,\n}: DeveloperSurfaceProps) {\n const id = useId();\n const titleId = `${id}-title`;\n const connectionsId = `${id}-connections`;\n const activityId = `${id}-activity`;\n const Title = headingLevel === 2 ? \"h2\" : \"h3\";\n const SectionTitle = headingLevel === 2 ? \"h3\" : \"h4\";\n\n return (\n \n
\n
\n \n \n Developer surface\n \n \n {title}\n \n

\n {description}\n

\n
\n {actions ? (\n
\n {actions}\n
\n ) : null}\n
\n\n
\n
\n
\n \n \n \n {project}\n \n \n {branch ? (\n \n \n \n {branch}\n \n \n ) : null}\n
\n\n
\n
\n
\n

\n Integrations\n

\n \n Connected development context\n \n
\n \n {connections.length} source{connections.length === 1 ? \"\" : \"s\"}\n \n
\n {connections.length > 0 ? (\n
\n {connections.map(({ id: connectionId, ...connection }) => (\n \n ))}\n
\n ) : (\n

\n No integration state is available. Do not infer connectivity or\n authority from an empty response.\n

\n )}\n
\n\n \n
\n
\n

\n Evidence\n

\n \n Recent invocations\n \n
\n \n {activity.length} receipt{activity.length === 1 ? \"\" : \"s\"}\n \n
\n {activity.length > 0 ? (\n
\n {activity.map(({ id: receiptId, ...receipt }) => (\n \n ))}\n
\n ) : (\n

\n No invocation receipts yet. Keep empty states explicit rather\n than fabricating execution history.\n

\n )}\n \n
\n\n \n {aside ?? (\n
\n
\n

\n Authority rule\n

\n

\n Presentation does not imply permission. Consumers must source\n authority from Coven, Threads, Psyche, or another canonical\n producer and render that state explicitly.\n

\n
\n
\n

\n Adapter boundary\n

\n

\n Feed this block normalized view models from the SDK, CLI,\n daemon, runtime registry, or application state. The component\n performs no discovery or mutation on its own.\n

\n
\n
\n )}\n \n
\n \n );\n}\n\nexport {\n DeveloperSurface,\n type DeveloperConnection,\n type DeveloperReceipt,\n type DeveloperSurfaceProps,\n};\n", + "type": "registry:block", + "target": "@components/blocks/developer-surface.tsx" + } + ], + "meta": { + "integrationSources": [ + "sdk", + "cli", + "daemon", + "runtime", + "project" + ], + "authority": "presentation-only", + "stableItemIds": true + }, + "type": "registry:block" +} diff --git a/public/r/registry.json b/public/r/registry.json index c238559..8246115 100644 --- a/public/r/registry.json +++ b/public/r/registry.json @@ -681,6 +681,113 @@ "target": "@components/blocks/session-header.tsx" } ] + }, + { + "name": "connection-status", + "type": "registry:component", + "title": "Connection status", + "description": "Integration health and host-supplied authority state for SDK, CLI, daemon, runtime, and project sources.", + "dependencies": [ + "lucide-react" + ], + "registryDependencies": [ + "@opencoven/cn", + "@opencoven/coven-theme" + ], + "files": [ + { + "path": "packages/ui/src/components/connection-status.tsx", + "type": "registry:component", + "target": "@components/connection-status.tsx" + } + ], + "meta": { + "states": [ + "connected", + "pending", + "degraded", + "disconnected", + "unavailable" + ], + "authority": [ + "read-only", + "proposal", + "mutating", + "local-authority" + ], + "authoritySource": "host-supplied" + } + }, + { + "name": "command-receipt", + "type": "registry:component", + "title": "Command receipt", + "description": "Presentation-safe CLI, SDK, daemon, or runtime invocation evidence with explicit lifecycle state.", + "dependencies": [ + "lucide-react" + ], + "registryDependencies": [ + "@opencoven/cn", + "@opencoven/coven-theme" + ], + "files": [ + { + "path": "packages/ui/src/components/command-receipt.tsx", + "type": "registry:component", + "target": "@components/command-receipt.tsx" + } + ], + "meta": { + "channels": [ + "cli", + "sdk", + "daemon", + "runtime" + ], + "states": [ + "accepted", + "running", + "succeeded", + "failed", + "blocked", + "unknown", + "recovery-required" + ], + "commandField": "displayCommand", + "protectedData": "pre-redacted-and-bounded" + } + }, + { + "name": "developer-surface", + "type": "registry:block", + "title": "Developer surface", + "description": "Project context, integration health, authority cues, and invocation evidence in one reusable development cockpit.", + "dependencies": [ + "lucide-react" + ], + "registryDependencies": [ + "@opencoven/connection-status", + "@opencoven/command-receipt", + "@opencoven/cn" + ], + "files": [ + { + "path": "packages/ui/src/blocks/developer-surface.tsx", + "type": "registry:block", + "target": "@components/blocks/developer-surface.tsx" + } + ], + "meta": { + "integrationSources": [ + "sdk", + "cli", + "daemon", + "runtime", + "project" + ], + "authority": "presentation-only", + "stableItemIds": true + } } ] } diff --git a/registry.json b/registry.json index c238559..8246115 100644 --- a/registry.json +++ b/registry.json @@ -681,6 +681,113 @@ "target": "@components/blocks/session-header.tsx" } ] + }, + { + "name": "connection-status", + "type": "registry:component", + "title": "Connection status", + "description": "Integration health and host-supplied authority state for SDK, CLI, daemon, runtime, and project sources.", + "dependencies": [ + "lucide-react" + ], + "registryDependencies": [ + "@opencoven/cn", + "@opencoven/coven-theme" + ], + "files": [ + { + "path": "packages/ui/src/components/connection-status.tsx", + "type": "registry:component", + "target": "@components/connection-status.tsx" + } + ], + "meta": { + "states": [ + "connected", + "pending", + "degraded", + "disconnected", + "unavailable" + ], + "authority": [ + "read-only", + "proposal", + "mutating", + "local-authority" + ], + "authoritySource": "host-supplied" + } + }, + { + "name": "command-receipt", + "type": "registry:component", + "title": "Command receipt", + "description": "Presentation-safe CLI, SDK, daemon, or runtime invocation evidence with explicit lifecycle state.", + "dependencies": [ + "lucide-react" + ], + "registryDependencies": [ + "@opencoven/cn", + "@opencoven/coven-theme" + ], + "files": [ + { + "path": "packages/ui/src/components/command-receipt.tsx", + "type": "registry:component", + "target": "@components/command-receipt.tsx" + } + ], + "meta": { + "channels": [ + "cli", + "sdk", + "daemon", + "runtime" + ], + "states": [ + "accepted", + "running", + "succeeded", + "failed", + "blocked", + "unknown", + "recovery-required" + ], + "commandField": "displayCommand", + "protectedData": "pre-redacted-and-bounded" + } + }, + { + "name": "developer-surface", + "type": "registry:block", + "title": "Developer surface", + "description": "Project context, integration health, authority cues, and invocation evidence in one reusable development cockpit.", + "dependencies": [ + "lucide-react" + ], + "registryDependencies": [ + "@opencoven/connection-status", + "@opencoven/command-receipt", + "@opencoven/cn" + ], + "files": [ + { + "path": "packages/ui/src/blocks/developer-surface.tsx", + "type": "registry:block", + "target": "@components/blocks/developer-surface.tsx" + } + ], + "meta": { + "integrationSources": [ + "sdk", + "cli", + "daemon", + "runtime", + "project" + ], + "authority": "presentation-only", + "stableItemIds": true + } } ] } diff --git a/registry/developer/registry.fragment.json b/registry/developer/registry.fragment.json new file mode 100644 index 0000000..aa17f0d --- /dev/null +++ b/registry/developer/registry.fragment.json @@ -0,0 +1,80 @@ +{ + "$schema": "https://ui.shadcn.com/schema/registry.json", + "items": [ + { + "name": "connection-status", + "type": "registry:component", + "title": "Connection status", + "description": "Integration health and host-supplied authority state for SDK, CLI, daemon, runtime, and project sources.", + "dependencies": ["lucide-react"], + "registryDependencies": ["cn", "coven-theme"], + "files": [ + { + "path": "../../packages/ui/src/components/connection-status.tsx", + "type": "registry:component", + "target": "@components/connection-status.tsx" + } + ], + "meta": { + "states": [ + "connected", + "pending", + "degraded", + "disconnected", + "unavailable" + ], + "authority": ["read-only", "proposal", "mutating", "local-authority"], + "authoritySource": "host-supplied" + } + }, + { + "name": "command-receipt", + "type": "registry:component", + "title": "Command receipt", + "description": "Presentation-safe CLI, SDK, daemon, or runtime invocation evidence with explicit lifecycle state.", + "dependencies": ["lucide-react"], + "registryDependencies": ["cn", "coven-theme"], + "files": [ + { + "path": "../../packages/ui/src/components/command-receipt.tsx", + "type": "registry:component", + "target": "@components/command-receipt.tsx" + } + ], + "meta": { + "channels": ["cli", "sdk", "daemon", "runtime"], + "states": [ + "accepted", + "running", + "succeeded", + "failed", + "blocked", + "unknown", + "recovery-required" + ], + "commandField": "displayCommand", + "protectedData": "pre-redacted-and-bounded" + } + }, + { + "name": "developer-surface", + "type": "registry:block", + "title": "Developer surface", + "description": "Project context, integration health, authority cues, and invocation evidence in one reusable development cockpit.", + "dependencies": ["lucide-react"], + "registryDependencies": ["connection-status", "command-receipt", "cn"], + "files": [ + { + "path": "../../packages/ui/src/blocks/developer-surface.tsx", + "type": "registry:block", + "target": "@components/blocks/developer-surface.tsx" + } + ], + "meta": { + "integrationSources": ["sdk", "cli", "daemon", "runtime", "project"], + "authority": "presentation-only", + "stableItemIds": true + } + } + ] +} diff --git a/scripts/check-registry-clean.mjs b/scripts/check-registry-clean.mjs new file mode 100644 index 0000000..17202d2 --- /dev/null +++ b/scripts/check-registry-clean.mjs @@ -0,0 +1,85 @@ +import { spawn } from "node:child_process"; + +const outputLimit = 1_000_000; +const timeoutMs = 15_000; + +function gitStatus() { + return new Promise((resolve, reject) => { + const child = spawn( + "git", + [ + "status", + "--porcelain=v1", + "--untracked-files=all", + "--", + "registry.json", + "public/r", + ], + { + cwd: process.cwd(), + stdio: ["ignore", "pipe", "pipe"], + }, + ); + + const stdout = []; + const stderr = []; + let stdoutBytes = 0; + let stderrBytes = 0; + let settled = false; + let timeout; + + const finish = (callback) => { + if (settled) return; + settled = true; + if (timeout) clearTimeout(timeout); + callback(); + }; + + const capture = (chunks, byteCount, chunk) => { + if (byteCount >= outputLimit) return byteCount; + const text = String(chunk); + const remaining = outputLimit - byteCount; + const bounded = Buffer.from(text).subarray(0, remaining).toString(); + chunks.push(bounded); + return byteCount + Buffer.byteLength(bounded); + }; + + child.stdout.on("data", (chunk) => { + stdoutBytes = capture(stdout, stdoutBytes, chunk); + }); + child.stderr.on("data", (chunk) => { + stderrBytes = capture(stderr, stderrBytes, chunk); + }); + child.once("error", (error) => finish(() => reject(error))); + child.once("exit", (code, signal) => { + finish(() => { + if (code === 0) { + resolve(stdout.join("")); + return; + } + + reject( + new Error( + `git status failed (${signal ?? code}): ${stderr.join("").trim() || "no stderr"}`, + ), + ); + }); + }); + + timeout = setTimeout(() => { + child.kill("SIGTERM"); + finish(() => reject(new Error("git status timed out"))); + }, timeoutMs); + }); +} + +const status = await gitStatus(); +const changed = status.trim(); + +if (changed) { + throw new Error( + `Generated registry output is not committed:\n${changed}\nRun pnpm registry:build and commit registry.json plus public/r.`, + ); +} + +console.log("Generated registry output is committed and clean."); diff --git a/scripts/compose-registry.mjs b/scripts/compose-registry.mjs index 0e658d7..d5a5ee1 100644 --- a/scripts/compose-registry.mjs +++ b/scripts/compose-registry.mjs @@ -7,6 +7,7 @@ const fragments = [ "registry/styles/registry.fragment.json", "registry/components/registry.fragment.json", "registry/blocks/registry.fragment.json", + "registry/developer/registry.fragment.json", ]; const rawItems = []; diff --git a/scripts/developer-visual-review.mjs b/scripts/developer-visual-review.mjs new file mode 100644 index 0000000..5f5ec06 --- /dev/null +++ b/scripts/developer-visual-review.mjs @@ -0,0 +1,572 @@ +import { spawn } from "node:child_process"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; + +const chromePath = process.env.CHROME_PATH; +const baseUrl = process.env.BASE_URL ?? "http://127.0.0.1:4173"; +const outputDir = path.resolve( + process.env.VISUAL_OUTPUT_DIR ?? "artifacts/visual-review", +); +const debuggingPort = Number(process.env.DEVELOPER_CHROME_PORT ?? 9244); +const requestTimeoutMs = 15_000; + +if (!chromePath) { + throw new Error("CHROME_PATH is required"); +} + +class CdpClient { + constructor(url) { + this.url = url; + this.socket = null; + this.nextId = 1; + this.pending = new Map(); + this.listeners = new Map(); + } + + async connect() { + await new Promise((resolve, reject) => { + const socket = new globalThis.WebSocket(this.url); + this.socket = socket; + + socket.addEventListener("open", resolve, { once: true }); + socket.addEventListener("error", reject, { once: true }); + socket.addEventListener("message", (event) => { + const message = JSON.parse(String(event.data)); + + if (message.id) { + const pending = this.pending.get(message.id); + if (!pending) return; + + this.pending.delete(message.id); + if (message.error) { + pending.reject( + new Error( + `${pending.method}: ${message.error.message ?? "CDP error"}`, + ), + ); + } else { + pending.resolve(message.result ?? {}); + } + return; + } + + for (const handler of this.listeners.get(message.method) ?? []) { + handler(message.params ?? {}); + } + }); + socket.addEventListener( + "close", + () => { + for (const pending of this.pending.values()) { + pending.reject(new Error("Chrome DevTools connection closed")); + } + this.pending.clear(); + }, + { once: true }, + ); + }); + } + + on(method, handler) { + const handlers = this.listeners.get(method) ?? []; + handlers.push(handler); + this.listeners.set(method, handlers); + + return () => { + this.listeners.set( + method, + (this.listeners.get(method) ?? []).filter( + (candidate) => candidate !== handler, + ), + ); + }; + } + + send(method, params = {}) { + if (!this.socket || this.socket.readyState !== globalThis.WebSocket.OPEN) { + throw new Error("Chrome DevTools connection is not open"); + } + + const id = this.nextId; + this.nextId += 1; + + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + this.pending.delete(id); + reject(new Error(`${method} timed out`)); + }, requestTimeoutMs); + + this.pending.set(id, { + method, + resolve: (value) => { + clearTimeout(timeout); + resolve(value); + }, + reject: (error) => { + clearTimeout(timeout); + reject(error); + }, + }); + this.socket.send(JSON.stringify({ id, method, params })); + }); + } + + waitForEvent(method, timeoutMs = requestTimeoutMs) { + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + removeListener(); + reject(new Error(`Timed out waiting for ${method}`)); + }, timeoutMs); + const removeListener = this.on(method, (params) => { + clearTimeout(timeout); + removeListener(); + resolve(params); + }); + }); + } + + close() { + this.socket?.close(); + } +} + +const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + +async function waitForJson(url, getSpawnError, timeoutMs = requestTimeoutMs) { + const deadline = Date.now() + timeoutMs; + let lastError; + + while (Date.now() < deadline) { + const spawnError = getSpawnError(); + if (spawnError) { + throw new Error(`Chrome failed to start: ${spawnError.message}`); + } + + try { + const response = await globalThis.fetch(url); + if (response.ok) return await response.json(); + lastError = new Error(`${response.status} ${response.statusText}`); + } catch (error) { + lastError = error; + } + + await sleep(150); + } + + throw new Error( + `Chrome debugging endpoint did not become ready: ${lastError}`, + ); +} + +async function waitForRender(client, selector, timeoutMs = requestTimeoutMs) { + const deadline = Date.now() + timeoutMs; + + while (Date.now() < deadline) { + const result = await client.send("Runtime.evaluate", { + expression: `Boolean(document.querySelector(${JSON.stringify(selector)}))`, + returnByValue: true, + }); + + if (result.result?.value === true) return; + await sleep(100); + } + + throw new Error(`Timed out waiting for ${selector}`); +} + +async function navigate(client, url) { + const loaded = client.waitForEvent("Page.loadEventFired"); + const response = await client.send("Page.navigate", { url }); + if (response.errorText) { + throw new Error(`Navigation failed: ${response.errorText}`); + } + await loaded; +} + +async function evaluateValue(client, expression, awaitPromise = false) { + const result = await client.send("Runtime.evaluate", { + expression, + awaitPromise, + returnByValue: true, + }); + + if (result.exceptionDetails) { + throw new Error( + result.exceptionDetails.exception?.description ?? + result.exceptionDetails.text ?? + "Runtime evaluation failed", + ); + } + + return result.result?.value; +} + +async function waitForChildExit(child, timeoutMs = 2_000) { + if (child.exitCode !== null || child.signalCode !== null) return true; + + return Promise.race([ + new Promise((resolve) => child.once("exit", () => resolve(true))), + sleep(timeoutMs).then(() => false), + ]); +} + +const scenarios = [ + { + name: "developer-dark-desktop", + width: 1440, + height: 1000, + scheme: "dark", + density: "default", + mobile: false, + }, + { + name: "developer-dark-mobile", + width: 390, + height: 844, + scheme: "dark", + density: "compact", + mobile: true, + }, + { + name: "developer-light-desktop", + width: 1440, + height: 1000, + scheme: "light", + density: "compact", + mobile: false, + }, + { + name: "developer-dark-mobile-text-200", + width: 390, + height: 900, + scheme: "dark", + density: "default", + mobile: true, + textScale: 2, + }, +]; + +const requiredText = [ + "OpenCoven development context", + "Coven daemon", + "OpenCoven SDK", + "Coven CLI", + "coven-code runtime", + "Read only", + "Local authority", + "Recent invocations", + "receipt demo:doctor:01", +]; + +await mkdir(outputDir, { recursive: true }); +const profileDir = await mkdtemp( + path.join(tmpdir(), "opencoven-developer-review-"), +); +const chromeOutput = []; +let chromeOutputBytes = 0; +const captureChromeOutput = (chunk) => { + if (chromeOutputBytes >= 1_000_000) return; + const text = String(chunk); + chromeOutputBytes += Buffer.byteLength(text); + chromeOutput.push(text); +}; + +const chrome = spawn( + chromePath, + [ + "--headless=new", + "--no-sandbox", + "--disable-dev-shm-usage", + "--disable-gpu", + "--hide-scrollbars", + "--no-first-run", + "--no-default-browser-check", + "--disable-background-networking", + "--disable-component-update", + `--remote-debugging-port=${debuggingPort}`, + `--user-data-dir=${profileDir}`, + "about:blank", + ], + { stdio: ["ignore", "pipe", "pipe"] }, +); + +chrome.stdout.on("data", captureChromeOutput); +chrome.stderr.on("data", captureChromeOutput); + +let chromeSpawnError; +chrome.once("error", (error) => { + chromeSpawnError = error; +}); + +let client; +const results = []; + +try { + const targets = await waitForJson( + `http://127.0.0.1:${debuggingPort}/json/list`, + () => chromeSpawnError, + ); + const page = targets.find((target) => target.type === "page"); + if (!page?.webSocketDebuggerUrl) { + throw new Error("Chrome did not expose a page debugging target"); + } + + client = new CdpClient(page.webSocketDebuggerUrl); + await client.connect(); + await client.send("Page.enable"); + await client.send("Runtime.enable"); + await client.send("Emulation.setEmulatedMedia", { + features: [{ name: "prefers-reduced-motion", value: "reduce" }], + }); + + for (const scenario of scenarios) { + const runtimeErrors = []; + const removeExceptionListener = client.on( + "Runtime.exceptionThrown", + ({ exceptionDetails }) => { + runtimeErrors.push( + exceptionDetails.exception?.description ?? + exceptionDetails.text ?? + "Uncaught runtime exception", + ); + }, + ); + const removeConsoleListener = client.on( + "Runtime.consoleAPICalled", + ({ type, args = [] }) => { + if (type === "error") { + runtimeErrors.push( + args + .map((argument) => argument.value ?? argument.description ?? "") + .join(" "), + ); + } + }, + ); + + try { + await client.send("Emulation.setDeviceMetricsOverride", { + width: scenario.width, + height: scenario.height, + deviceScaleFactor: 1, + mobile: scenario.mobile, + screenWidth: scenario.width, + screenHeight: scenario.height, + }); + + await navigate(client, new URL("/", baseUrl).href); + await evaluateValue( + client, + `localStorage.setItem("coven-ui:scheme", ${JSON.stringify( + scenario.scheme, + )}); localStorage.setItem("coven-ui:density", ${JSON.stringify( + scenario.density, + )});`, + ); + await navigate(client, new URL("/developer", baseUrl).href); + await waitForRender(client, '[data-slot="developer-surface"]'); + await evaluateValue( + client, + `(() => { + document.documentElement.style.fontSize = ${JSON.stringify( + scenario.textScale ? `${scenario.textScale * 100}%` : "", + )}; + })()`, + ); + await evaluateValue( + client, + `(async () => { + await document.fonts.ready; + await new Promise((resolve) => + requestAnimationFrame(() => requestAnimationFrame(resolve)), + ); + return true; + })()`, + true, + ); + + const layout = await evaluateValue( + client, + `(() => { + const root = document.documentElement; + const surface = document.querySelector('[data-slot="developer-surface"]'); + const connections = [...document.querySelectorAll('[data-slot="connection-status"]')]; + const receipts = [...document.querySelectorAll('[data-slot="command-receipt"]')]; + const ids = [...document.querySelectorAll('[id]')].map((element) => element.id); + const bodyText = document.body.innerText; + const clipped = [surface, ...connections, ...receipts] + .filter(Boolean) + .map((element) => ({ + slot: element.getAttribute("data-slot") ?? element.tagName.toLowerCase(), + overflow: Math.max(0, element.scrollWidth - element.clientWidth), + })) + .filter(({ overflow }) => overflow > 1); + const rect = surface?.getBoundingClientRect(); + const style = surface ? getComputedStyle(surface) : null; + + return { + pathname: location.pathname, + viewportWidth: root.clientWidth, + scrollWidth: root.scrollWidth, + horizontalOverflow: Math.max(0, root.scrollWidth - root.clientWidth), + surfaceVisible: Boolean( + surface && + style?.display !== "none" && + style?.visibility !== "hidden" && + rect?.width > 0 && + rect?.height > 0 + ), + connectionCount: connections.length, + receiptCount: receipts.length, + requiredText: ${JSON.stringify(requiredText)}.filter( + (text) => !bodyText.includes(text), + ), + fakeControls: ["Open CLI", "Inspect project"].filter((text) => + bodyText.includes(text), + ), + scheme: root.classList.contains("dark") ? "dark" : "light", + density: root.dataset.density, + duplicateIds: ids.filter((id, index) => ids.indexOf(id) !== index), + internallyClipped: clipped, + reducedMotion: matchMedia("(prefers-reduced-motion: reduce)").matches, + }; + })()`, + ); + + const failures = []; + if (layout.pathname !== "/developer") { + failures.push(`expected /developer, received ${layout.pathname}`); + } + if (!layout.surfaceVisible) { + failures.push("developer surface is not visible"); + } + if (layout.horizontalOverflow > 1) { + failures.push( + `horizontal overflow is ${layout.horizontalOverflow}px at ${scenario.width}px`, + ); + } + if (layout.internallyClipped.length > 0) { + failures.push( + `internally clipped surfaces: ${layout.internallyClipped + .map(({ slot, overflow }) => `${slot} (${overflow}px)`) + .join(", ")}`, + ); + } + if (layout.connectionCount !== 4) { + failures.push(`expected 4 connections, got ${layout.connectionCount}`); + } + if (layout.receiptCount !== 3) { + failures.push(`expected 3 receipts, got ${layout.receiptCount}`); + } + if (layout.requiredText.length > 0) { + failures.push(`missing text: ${layout.requiredText.join(", ")}`); + } + if (layout.fakeControls.length > 0) { + failures.push(`fake controls: ${layout.fakeControls.join(", ")}`); + } + if (layout.scheme !== scenario.scheme) { + failures.push( + `expected ${scenario.scheme} scheme, received ${layout.scheme}`, + ); + } + if (layout.density !== scenario.density) { + failures.push( + `expected ${scenario.density} density, received ${layout.density}`, + ); + } + if (layout.duplicateIds.length > 0) { + failures.push(`duplicate IDs: ${layout.duplicateIds.join(", ")}`); + } + if (!layout.reducedMotion) { + failures.push("reduced-motion media query was not active"); + } + if (runtimeErrors.length > 0) { + failures.push(`runtime errors: ${runtimeErrors.join(" | ")}`); + } + + const screenshot = await client.send("Page.captureScreenshot", { + format: "png", + fromSurface: true, + captureBeyondViewport: false, + }); + const screenshotPath = path.join(outputDir, `${scenario.name}.png`); + await writeFile(screenshotPath, Buffer.from(screenshot.data, "base64")); + + results.push({ + ...scenario, + layout, + failures, + screenshot: path.basename(screenshotPath), + }); + } finally { + removeExceptionListener(); + removeConsoleListener(); + } + } + + const summary = { + generatedAt: new Date().toISOString(), + baseUrl, + passed: results.every((result) => result.failures.length === 0), + scenarios: results, + }; + + await writeFile( + path.join(outputDir, "developer-review.json"), + `${JSON.stringify(summary, null, 2)}\n`, + ); + await writeFile( + path.join(outputDir, "developer-review.md"), + [ + "# OpenCoven UI developer-surface review", + "", + `Result: **${summary.passed ? "PASS" : "FAIL"}**`, + "", + "| Scenario | Viewport | Scheme | Density | Overflow | Result |", + "|---|---:|---|---|---:|---|", + ...results.map( + (result) => + `| ${result.name} | ${result.width}×${result.height} | ${result.scheme} | ${result.density} | ${result.layout.horizontalOverflow}px | ${ + result.failures.length === 0 ? "PASS" : result.failures.join("; ") + } |`, + ), + "", + ].join("\n"), + ); + + const failures = results.flatMap((result) => + result.failures.map((failure) => `${result.name}: ${failure}`), + ); + if (failures.length > 0) { + throw new Error( + `Developer visual review failed:\n- ${failures.join("\n- ")}`, + ); + } + + console.log( + `Captured ${results.length} passing developer-surface scenarios.`, + ); +} catch (error) { + await writeFile( + path.join(outputDir, "developer-chrome.log"), + `${chromeOutput.join("")}\n`, + ); + throw error; +} finally { + client?.close(); + chrome.kill("SIGTERM"); + + const exited = await waitForChildExit(chrome); + if (!exited && chrome.exitCode === null && chrome.signalCode === null) { + chrome.kill("SIGKILL"); + await waitForChildExit(chrome); + } + + await rm(profileDir, { + recursive: true, + force: true, + maxRetries: 5, + retryDelay: 100, + }); +} diff --git a/scripts/test-registry-consumer.mjs b/scripts/test-registry-consumer.mjs index 3bec70b..88337e3 100644 --- a/scripts/test-registry-consumer.mjs +++ b/scripts/test-registry-consumer.mjs @@ -145,28 +145,35 @@ function run(command, args) { } try { - const itemUrl = `http://127.0.0.1:${address.port}/composer.json`; - await run("pnpm", [ - "exec", - "shadcn", - "add", - itemUrl, - "--cwd", - consumer, - "--yes", - ]); + const items = ["composer", "developer-surface"]; + for (const item of items) { + await run("pnpm", [ + "exec", + "shadcn", + "add", + `http://127.0.0.1:${address.port}/${item}.json`, + "--cwd", + consumer, + "--yes", + ]); + } + await run("pnpm", ["--dir", consumer, "install", "--ignore-workspace"]); await run("pnpm", ["--dir", consumer, "exec", "tsc", "--noEmit"]); - const composer = await readFile( - path.join(consumer, "src", "components", "blocks", "composer.tsx"), - "utf8", - ); - if (composer.includes("@opencoven/ui")) { - throw new Error("Installed source retained package-internal aliases"); + for (const [relativePath, label] of [ + ["src/components/blocks/composer.tsx", "composer"], + ["src/components/blocks/developer-surface.tsx", "developer surface"], + ]) { + const source = await readFile(path.join(consumer, relativePath), "utf8"); + if (source.includes("@opencoven/ui")) { + throw new Error(`${label} retained package-internal aliases`); + } } - console.log(`Clean consumer installed and type-checked ${itemUrl}.`); + console.log( + `Clean consumer installed and type-checked ${items.join(", ")} from the generated registry.`, + ); } finally { await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve())), diff --git a/scripts/verify-contracts.mjs b/scripts/verify-contracts.mjs index 6a33569..a461a82 100644 --- a/scripts/verify-contracts.mjs +++ b/scripts/verify-contracts.mjs @@ -7,20 +7,36 @@ const read = (relativePath) => readFile(path.join(root, relativePath), "utf8"); const [ componentsJson, packageJson, + rootPackageJson, tokens, specimenCss, specimenFixes, specimenApp, + specimenMain, + connectionStatus, + commandReceipt, + developerSurface, + developerDocs, + developerRegistryJson, + registryCleanCheck, button, tooltip, menu, ] = await Promise.all([ read("components.json"), read("packages/ui/package.json"), + read("package.json"), read("packages/ui/src/styles/globals.css"), read("apps/specimens/src/specimens.css"), read("apps/specimens/src/specimens-fixes.css"), read("apps/specimens/src/app.tsx"), + read("apps/specimens/src/main.tsx"), + read("packages/ui/src/components/connection-status.tsx"), + read("packages/ui/src/components/command-receipt.tsx"), + read("packages/ui/src/blocks/developer-surface.tsx"), + read("docs/developer-surface.md"), + read("registry/developer/registry.fragment.json"), + read("scripts/check-registry-clean.mjs"), read("packages/ui/src/components/ui/button.tsx"), read("packages/ui/src/components/ui/tooltip.tsx"), read("packages/ui/src/components/ui/dropdown-menu.tsx"), @@ -28,6 +44,12 @@ const [ const config = JSON.parse(componentsJson); const manifest = JSON.parse(packageJson); +const workspaceManifest = JSON.parse(rootPackageJson); +const developerRegistry = JSON.parse(developerRegistryJson); +const commandReceiptItem = developerRegistry.items.find( + (item) => item.name === "command-receipt", +); +const normalizedDeveloperDocs = developerDocs.replace(/\s+/g, " "); const assertions = [ ["style is base-nova", config.style === "base-nova"], ["base color is zinc", config.tailwind.baseColor === "zinc"], @@ -45,6 +67,20 @@ const assertions = [ name.startsWith("react-aria"), ), ], + [ + "developer UI does not depend on SDK or CLI runtimes", + ![ + ...Object.keys(manifest.dependencies), + ...Object.keys(manifest.peerDependencies), + ].some( + (name) => + name === "@opencoven/sdk" || + name === "@opencoven/sdk-core" || + name === "@opencoven/cave-client" || + name === "@opencoven/coven-client" || + name === "@opencoven/cli", + ), + ], [ "tool classes are canonical", ["read", "write", "exec", "net"].every( @@ -133,6 +169,80 @@ const assertions = [ "filled action variants are explicit", button.includes("primary:") && button.includes("presence:"), ], + [ + "connection state is generic and accessible", + connectionStatus.includes('label: "Pending"') && + !connectionStatus.includes('label: "Connecting"') && + connectionStatus.includes("aria-label={`${kind} ${name}"), + ], + [ + "command receipts use presentation-safe display data", + commandReceipt.includes("displayCommand: string") && + !commandReceipt.includes("\n command: string") && + commandReceipt.includes('"recovery-required"') && + commandReceipt.includes('label: "Unknown"'), + ], + [ + "developer surface uses stable item and heading identity", + developerSurface.includes("useId") && + developerSurface.includes( + "type DeveloperConnection = ConnectionStatusProps & { id: string }", + ) && + developerSurface.includes( + "type DeveloperReceipt = CommandReceiptProps & { id: string }", + ) && + developerSurface.includes( + "connections: readonly DeveloperConnection[]", + ) && + developerSurface.includes("activity?: readonly DeveloperReceipt[]") && + developerSurface.includes("No integration state is available"), + ], + [ + "developer surface remains presentation only", + developerSurface.includes("Presentation does not imply permission") && + developerSurface.includes( + "performs no discovery or mutation on its own", + ) && + !developerSurface.includes("@opencoven/sdk") && + !developerSurface.includes("@opencoven/cli"), + ], + [ + "developer route is explicit", + specimenMain.includes('normalizedPath === "/developer"') && + specimenMain.includes(""), + ], + [ + "developer docs preserve current SDK and CLI truth", + normalizedDeveloperDocs.includes("@opencoven/cli") && + normalizedDeveloperDocs.includes( + "private repository-development workspace", + ) && + normalizedDeveloperDocs.includes("currently private and not published") && + normalizedDeveloperDocs.includes( + "first public release is deliberately read-only", + ), + ], + [ + "developer docs prohibit unreviewed protected data", + developerDocs.includes("raw process arguments") && + developerDocs.includes("prompts, message bodies") && + developerDocs.includes("Renaming raw data to") && + developerDocs.includes("displayCommand"), + ], + [ + "developer registry advertises the bounded receipt contract", + commandReceiptItem?.meta?.commandField === "displayCommand" && + commandReceiptItem?.meta?.protectedData === "pre-redacted-and-bounded" && + ["accepted", "unknown", "recovery-required"].every((status) => + commandReceiptItem?.meta?.states?.includes(status), + ), + ], + [ + "registry drift check includes untracked generated output", + workspaceManifest.scripts["registry:check"].includes( + "check-registry-clean.mjs", + ) && registryCleanCheck.includes('"--untracked-files=all"'), + ], ]; const failed = assertions.filter(([, passed]) => !passed); diff --git a/vercel.json b/vercel.json index 3dac685..07b8f6c 100644 --- a/vercel.json +++ b/vercel.json @@ -1,7 +1,7 @@ { "framework": "vite", "installCommand": "pnpm install --frozen-lockfile", - "buildCommand": "pnpm build", + "buildCommand": "pnpm registry:build && pnpm build", "outputDirectory": "apps/specimens/dist", "rewrites": [ {