Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,4 @@ coverage/
test-results/
playwright-report/
*.tsbuildinfo
apps/web/public/take-home/
4 changes: 2 additions & 2 deletions apps/web/app/permalink.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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(/^#\/?/, "");
Expand Down
11 changes: 9 additions & 2 deletions apps/web/app/studio.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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."
Expand Down Expand Up @@ -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<string | null>(null);
Expand Down Expand Up @@ -597,6 +600,9 @@ export function Studio() {
<button data-testid="view-canvas" className={btnClass(view === "canvas")} onClick={() => setView("canvas")}>
restyle it
</button>
<button data-testid="view-home" className={btnClass(view === "home")} onClick={() => setView("home")}>
take it home
</button>
</div>
<p data-testid="view-help" style={{ fontSize: 13, color: "var(--fg-dim)", margin: "0 0 20px", maxWidth: 720 }}>
{viewHelp(view, agentOnline)}
Expand Down Expand Up @@ -625,6 +631,7 @@ export function Studio() {
/>
)}
{view === "canvas" && scenario && <RestyleView scenario={scenario} />}
{view === "home" && <TakeHomeView agentOnline={agentOnline} />}
{/* Orientation, below the primary experience: the approved pipeline
language and the existing capabilities, stated once, compactly. */}
<section aria-label="how it works" style={{ marginTop: 44, fontSize: 12 }}>
Expand Down
209 changes: 209 additions & 0 deletions apps/web/app/take-home-view.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<button
data-testid={testid}
className={btnClass()}
onClick={async () => {
try {
await navigator.clipboard.writeText(text);
setCopied(true);
setTimeout(() => setCopied(false), 1600);
} catch {
/* clipboard unavailable: the text is selectable in the block above */
}
}}
>
{copied ? "copied" : "copy"}
</button>
);
}

/** 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 (
<section
data-testid="validate-rejected"
style={{ border: "1px solid var(--err-line)", background: "var(--err-soft)", borderRadius: 6, padding: "12px 16px", fontSize: 13, marginTop: 10 }}
>
The validator said no: <code>{outcome.reason}</code>. Nothing was evaluated.
</section>
);
}
const { report } = outcome;
return (
<div data-testid="validate-report" style={{ marginTop: 10 }}>
<p data-testid="validate-verdict" style={{ fontSize: 13, margin: "0 0 6px", fontWeight: 600 }}>
{report.pass
? "All gates pass. This surface is contract-valid."
: `Gates failed: ${report.errorCount} error finding${report.errorCount === 1 ? "" : "s"}.`}
</p>
<p style={{ fontSize: 12, color: "var(--fg-dim)", margin: "0 0 8px", fontFamily: "var(--mono)" }}>
{(report.gates as GateReport[]).map((g) => `${g.gate} ${g.status}`).join(" · ")}
</p>
{(report.findings as Finding[]).map((f, i) => (
<div
key={`${f.ruleId}-${i}`}
data-testid="validate-finding"
style={{ border: "1px dashed var(--line)", borderRadius: 4, padding: "8px 10px", marginBottom: 6, fontSize: 12 }}
>
<p style={{ margin: 0, fontFamily: "var(--mono)" }}>
<strong>{f.ruleId}</strong> · {f.level} · at {f.location.path}
</p>
<p style={{ margin: "4px 0 0" }}>{f.message}</p>
<p style={{ margin: "4px 0 0", color: "var(--fg-dim)" }}>{f.rationale}</p>
</div>
))}
<details style={{ fontSize: 12, marginTop: 6 }}>
<summary style={{ cursor: "pointer" }}>the report as the linter prints it</summary>
<pre data-testid="validate-text" tabIndex={0} style={preStyle}>{outcome.text}</pre>
</details>
</div>
);
}

export function TakeHomeView({ agentOnline }: { agentOnline: boolean | null }) {
const [pasted, setPasted] = useState("");
const [outcome, setOutcome] = useState<ValidateOutcome | null>(null);
const config = mcpConfig();

return (
<div data-testid="take-home">
<section style={sectionStyle} aria-label="use the design system from your editor">
<h2 style={{ fontSize: 14, margin: "0 0 6px" }}>The design system in your editor</h2>
<p style={{ fontSize: 13, color: "var(--fg-body)", margin: "0 0 4px", maxWidth: 720, lineHeight: 1.55 }}>
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&apos;s Astryx contract; download both, set
the absolute path, and add the config to your MCP client.
</p>
<p data-testid="mcp-version-note" style={{ fontSize: 12, color: "var(--fg-dim)", margin: "0 0 4px" }}>
Requires ds-mcp {DS_MCP_RANGE}: earlier versions serve a pre-0.1.1 generation schema that constrains
models differently than this studio&apos;s pipeline.
</p>
<pre data-testid="mcp-config" tabIndex={0} aria-label="the MCP client config" style={preStyle}>{config}</pre>
<div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
<CopyButton text={config} testid="mcp-config-copy" />
<button data-testid="mcp-config-download" className={btnClass()} onClick={() => download("mcp-config.json", config)}>
download the config
</button>
<a data-testid="contract-download" className={linkClass} style={{ alignSelf: "center", fontSize: 13 }} href={CONTRACT_DOWNLOAD_PATH} download>
download the contract (astryx.dspack.json)
</a>
</div>
<p style={{ fontSize: 12, color: "var(--fg-dim)", margin: "8px 0 0" }}>
The downloaded contract is the byte-synced copy of dspack main this studio validates against. Replace{" "}
<code>{CONTRACT_PATH_PLACEHOLDER}</code> with where you saved it.
</p>
</section>

<section style={sectionStyle} aria-label="validate a surface in this page">
<h2 style={{ fontSize: 14, margin: "0 0 6px" }}>The validator, right here</h2>
<p style={{ fontSize: 13, color: "var(--fg-body)", margin: "0 0 8px", maxWidth: 720, lineHeight: 1.55 }}>
Paste a dspack surface and the real linter evaluates it against the studio&apos;s contract: S1 surface
schema, S2 contract vocabulary, S3 governance rules. This runs entirely in this page; nothing you paste
leaves your browser.
</p>
<textarea
data-testid="validate-input"
aria-label="a dspack surface document to validate"
value={pasted}
onChange={(e) => setPasted(e.target.value)}
rows={10}
spellCheck={false}
placeholder='{ "dspackSurface": "0.1", "system": "Astryx", "intent": "destructive-action", "root": { ... } }'
style={{ width: "100%", padding: 10, borderRadius: 2, border: "1px solid var(--line)", background: "var(--bg-1)", color: "inherit", fontFamily: "var(--mono)", fontSize: 12, lineHeight: 1.5 }}
/>
<div style={{ display: "flex", gap: 8, marginTop: 8, flexWrap: "wrap" }}>
<button data-testid="validate-run" className={btnClass(true)} onClick={() => setOutcome(validatePasted(pasted))}>
validate
</button>
<button
data-testid="validate-load-example"
className={btnClass()}
onClick={() => {
setPasted(caughtExample().surfaceJson);
setOutcome(null);
}}
>
load a caught example
</button>
</div>
<p style={{ fontSize: 12, color: "var(--fg-dim)", margin: "8px 0 0" }}>
The caught example is fixture-001&apos;s attempt 0, exactly as the recorded live run proposed it before
the S3 gate failed it. Validating it here reproduces the recorded findings.
</p>
{outcome && <ReportView outcome={outcome} />}
</section>

<section style={sectionStyle} aria-label="run the pipeline locally">
<h2 style={{ fontSize: 14, margin: "0 0 6px" }}>The pipeline on your machine</h2>
<p style={{ fontSize: 13, color: "var(--fg-body)", margin: "0 0 8px", maxWidth: 720, lineHeight: 1.55 }}>
Live governed generation needs the local agent. It wraps the published dspack-gen pipeline behind an
AG-UI endpoint; models run through your local Ollama, and no credentials pass through the browser.
</p>
<pre data-testid="local-agent-commands" tabIndex={0} aria-label="commands to run the local agent" style={preStyle}>{LOCAL_AGENT_COMMANDS.join("\n")}</pre>
<div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
<CopyButton text={LOCAL_AGENT_COMMANDS.join("\n")} testid="local-agent-copy" />
</div>
<p data-testid="local-agent-status" style={{ fontSize: 12, color: "var(--fg-dim)", margin: "8px 0 0" }}>
{agentOnline
? "A local agent is answering right now: run it live is active in this studio."
: "Once it answers on localhost:8787, this studio's run it live and break it modes use it directly."}
</p>
Comment on lines +201 to +205
</section>
</div>
);
}
77 changes: 77 additions & 0 deletions apps/web/app/take-home.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
/**
* FM-11 unit gates.
*
* The findings-agreement test is the honesty check in the receipts spirit:
* the in-browser validator and the recorded pipeline must say the same
* thing about the same surface — byte-compared findings, not vibes. The
* version assertions are literal so a future copy edit cannot silently
* reference a ds-mcp older than 0.3.1 (which serves the pre-0.1.1 schema).
*/
import { describe, expect, it } from "vitest";
import {
CONTRACT_PATH_PLACEHOLDER,
DS_MCP_RANGE,
LOCAL_AGENT_COMMANDS,
caughtExample,
mcpConfig,
validatePasted,
} from "./take-home";
import contractJson from "@dspack-studio/contracts/astryx.dspack.json";

describe("mcp config", () => {
it("pins ds-mcp to ^0.3.1 or a higher floor, never 0.3.0 or older", () => {
expect(DS_MCP_RANGE.startsWith("^")).toBe(true);
const [major, minor, patch] = DS_MCP_RANGE.slice(1).split(".").map(Number);
expect(major * 1_000_000 + minor * 1_000 + patch).toBeGreaterThanOrEqual(3_001);
const config = JSON.parse(mcpConfig()) as {
mcpServers: Record<string, { command: string; args: string[] }>;
};
const server = config.mcpServers["design-system"];
expect(server.command).toBe("npx");
expect(server.args).toContain(`@aestheticfunction/ds-mcp@${DS_MCP_RANGE}`);
expect(server.args).toContain("--dspack");
expect(server.args).toContain(CONTRACT_PATH_PLACEHOLDER);
});

it("references no unpublished package anywhere in the visitor-facing commands", () => {
for (const cmd of LOCAL_AGENT_COMMANDS) {
expect(cmd).not.toMatch(/dspack-studio-agent/);
}
});
});

describe("validatePasted", () => {
it("reports parse errors verbatim, evaluating nothing", () => {
const out = validatePasted("{this is not json");
expect(out.kind).toBe("rejected");
if (out.kind === "rejected") expect(out.reason).toMatch(/^not JSON: /);
});

it("says so when nothing is pasted", () => {
expect(validatePasted(" ")).toEqual({ kind: "rejected", reason: "nothing pasted yet" });
});

it("passes the contract's own worked example clean (S1/S2/S3)", () => {
const example = (contractJson as { examples: Array<{ surface: unknown }> }).examples[0];
const out = validatePasted(JSON.stringify(example.surface));
expect(out.kind).toBe("report");
if (out.kind === "report") {
expect(out.report.pass).toBe(true);
expect(out.report.gates.map((g) => g.status)).toEqual(["PASS", "PASS", "PASS"]);
}
});
});

describe("honesty: the browser validator agrees with the recorded pipeline", () => {
it("fixture-001's caught attempt-0 surface reproduces the recorded findings, byte for byte", () => {
const { surfaceJson, recordedFindings } = caughtExample();
const out = validatePasted(surfaceJson);
expect(out.kind).toBe("report");
if (out.kind !== "report") return;
expect(out.report.pass).toBe(false);
// The recorded run's dspack.gates event carries the findings the live
// pipeline computed in 2026 on gemma4:e4b's real output; the validator
// bundled into this page must produce the identical list.
expect(JSON.stringify(out.report.findings, null, 2)).toBe(JSON.stringify(recordedFindings, null, 2));
});
});
Loading
Loading