diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a694b59..1e264eb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -32,6 +32,7 @@ jobs: pnpm --filter @dspack-studio/replay test pnpm --filter @dspack-studio/scenarios test pnpm --filter agent test + pnpm --filter web test - name: Type checks run: pnpm -r typecheck - name: Static export (the deploy artifact) diff --git a/.gitignore b/.gitignore index d320d4d..2c42b8e 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,4 @@ coverage/ test-results/ playwright-report/ *.tsbuildinfo +apps/web/public/take-home/ diff --git a/apps/web/app/permalink.ts b/apps/web/app/permalink.ts index 2a05c78..77f1af2 100644 --- a/apps/web/app/permalink.ts +++ b/apps/web/app/permalink.ts @@ -20,7 +20,7 @@ */ export interface PermalinkState { scenario?: string; - view?: "replay" | "live" | "break" | "canvas"; + view?: "replay" | "live" | "break" | "canvas" | "home"; fixture?: string; fork?: { parentKey: string; forkIndex: number }; breakCondition?: string; @@ -30,7 +30,7 @@ export interface PermalinkState { } const PANELS = new Set(["receipt", "wire", "pipeline"]); -const VIEWS = new Set(["replay", "live", "break", "canvas"]); +const VIEWS = new Set(["replay", "live", "break", "canvas", "home"]); export function parsePermalink(hash: string): { state: PermalinkState; error?: string } { const raw = hash.replace(/^#\/?/, ""); diff --git a/apps/web/app/studio.tsx b/apps/web/app/studio.tsx index 5456013..7bd4716 100644 --- a/apps/web/app/studio.tsx +++ b/apps/web/app/studio.tsx @@ -19,15 +19,18 @@ const AGENT_URL = process.env.NEXT_PUBLIC_AGENT_URL ?? "http://localhost:8787"; import { LiveView } from "./live-view"; import { BreakView } from "./break-view"; import { RestyleView } from "./restyle-view"; +import { TakeHomeView } from "./take-home-view"; import { useAgentStatus } from "./use-agent-status"; import { btnClass, linkClass } from "./ui"; /** One plain sentence of context per view, shown under the switcher. */ -function viewHelp(view: "replay" | "live" | "break" | "canvas", agentOnline: boolean | null): string { +function viewHelp(view: "replay" | "live" | "break" | "canvas" | "home", agentOnline: boolean | null): string { const offline = agentOnline === false; switch (view) { case "replay": return "Recorded real runs, replayed from their event streams. Works everywhere, no setup."; + case "home": + return "The ecosystem in your own editor: the MCP config, the contract, the validator, and the local agent. Same packages, published versions."; case "live": return offline ? "Streams a new run from an agent on your machine. The local agent is offline; start it: pnpm --filter agent dev. Replay works without it." @@ -410,7 +413,7 @@ function ReplayPane({ scenario, deepLink, onLinkError }: { scenario: Scenario; d export function Studio() { const [scenarioId, setScenarioId] = useState(readyScenarios[0]?.id); - const [view, setView] = useState<"replay" | "live" | "break" | "canvas">("replay"); + const [view, setView] = useState<"replay" | "live" | "break" | "canvas" | "home">("replay"); // A planned scenario the visitor tapped: its "what it needs" line replaces // the tagline until a ready scenario is chosen or it is tapped again. const [revealedPlanned, setRevealedPlanned] = useState(null); @@ -597,6 +600,9 @@ export function Studio() { +

{viewHelp(view, agentOnline)} @@ -625,6 +631,7 @@ export function Studio() { /> )} {view === "canvas" && scenario && } + {view === "home" && } {/* Orientation, below the primary experience: the approved pipeline language and the existing capabilities, stated once, compactly. */}

diff --git a/apps/web/app/take-home-view.tsx b/apps/web/app/take-home-view.tsx new file mode 100644 index 0000000..630b4bd --- /dev/null +++ b/apps/web/app/take-home-view.tsx @@ -0,0 +1,209 @@ +"use client"; + +/** + * FM-11: take it home. Three honest paths from liking the studio to running + * the ecosystem yourself: the ds-mcp config for your editor, the real + * validator in this page, and the local agent for live governed generation. + * Everything here names published versions only; the validator and the + * contract are the same ones the studio runs. Capabilities that need the + * local agent say so plainly. + */ +import { useState } from "react"; +import type { Finding, GateReport } from "@aestheticfunction/dspack-gen/core"; +import { + CONTRACT_DOWNLOAD_PATH, + CONTRACT_PATH_PLACEHOLDER, + DS_MCP_RANGE, + LOCAL_AGENT_COMMANDS, + caughtExample, + mcpConfig, + validatePasted, + type ValidateOutcome, +} from "./take-home"; +import { btnClass, linkClass } from "./ui"; + +const sectionStyle: React.CSSProperties = { + border: "1px solid var(--line)", + borderRadius: 6, + padding: "14px 16px", + marginBottom: 14, +}; + +const preStyle: React.CSSProperties = { + margin: "8px 0", + padding: 10, + background: "var(--bg-2)", + borderRadius: 3, + overflow: "auto", + fontFamily: "var(--mono)", + fontSize: 12, + lineHeight: 1.5, +}; + +function download(filename: string, text: string, type = "application/json") { + const url = URL.createObjectURL(new Blob([text], { type })); + const a = document.createElement("a"); + a.href = url; + a.download = filename; + a.click(); + URL.revokeObjectURL(url); +} + +function CopyButton({ text, testid }: { text: string; testid: string }) { + const [copied, setCopied] = useState(false); + return ( + + ); +} + +/** One gate line + its findings, the same information the gates tab shows for a run. */ +function ReportView({ outcome }: { outcome: ValidateOutcome }) { + if (outcome.kind === "rejected") { + return ( +
+ The validator said no: {outcome.reason}. Nothing was evaluated. +
+ ); + } + const { report } = outcome; + return ( +
+

+ {report.pass + ? "All gates pass. This surface is contract-valid." + : `Gates failed: ${report.errorCount} error finding${report.errorCount === 1 ? "" : "s"}.`} +

+

+ {(report.gates as GateReport[]).map((g) => `${g.gate} ${g.status}`).join(" · ")} +

+ {(report.findings as Finding[]).map((f, i) => ( +
+

+ {f.ruleId} · {f.level} · at {f.location.path} +

+

{f.message}

+

{f.rationale}

+
+ ))} +
+ the report as the linter prints it +
{outcome.text}
+
+
+ ); +} + +export function TakeHomeView({ agentOnline }: { agentOnline: boolean | null }) { + const [pasted, setPasted] = useState(""); + const [outcome, setOutcome] = useState(null); + const config = mcpConfig(); + + return ( +
+
+

The design system in your editor

+

+ ds-mcp serves a dspack contract to MCP coding agents: components, tokens, patterns, the compiled + generation context, and the same S1/S2/S3 validator this studio runs. It is read-only, makes no network + calls, and runs from npm. This config points it at the studio's Astryx contract; download both, set + the absolute path, and add the config to your MCP client. +

+

+ Requires ds-mcp {DS_MCP_RANGE}: earlier versions serve a pre-0.1.1 generation schema that constrains + models differently than this studio's pipeline. +

+
{config}
+
+ + + + download the contract (astryx.dspack.json) + +
+

+ The downloaded contract is the byte-synced copy of dspack main this studio validates against. Replace{" "} + {CONTRACT_PATH_PLACEHOLDER} with where you saved it. +

+
+ +
+

The validator, right here

+

+ Paste a dspack surface and the real linter evaluates it against the studio's contract: S1 surface + schema, S2 contract vocabulary, S3 governance rules. This runs entirely in this page; nothing you paste + leaves your browser. +

+