diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7d75c09..f559c41 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -43,6 +43,7 @@ jobs: - run: pnpm test - run: pnpm build - run: pnpm check:public-packages + - run: pnpm check:public-api - run: pnpm pack:dry-run safety: @@ -74,4 +75,5 @@ jobs: exit "$status" fi - run: pnpm exec playwright install --with-deps chromium webkit + - run: pnpm test:gallery - run: pnpm test:safety diff --git a/README.md b/README.md index a6bb41b..60f8ae1 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,18 @@ The public package boundary is: pnpm install cp apps/server/.env.example apps/server/.env # edit apps/server/.env and set ANTHROPIC_API_KEY +pnpm dev:gallery +``` + +Open `http://localhost:5174`. + +The Surface Gallery is the first OSS demo: curated live presets that show +static, host-resource, host-action, approval-gated, component-island, and +worker-backed Summon surfaces without exposing workbench controls. + +For the maintainer workbench: + +```sh pnpm dev:all ``` @@ -71,6 +83,8 @@ explicit accepted `SurfacePlan`. ## Demo Map +- `examples/surface-gallery` - first-run OSS gallery with curated live presets, + compact host contracts, a sandboxed surface, and a small event strip. - `/generate.html` - contract cockpit with scenario grants, surface plans, static/declarative/scripted/worker tiers, component islands, host resources, token overrides, repair diagnostics, edit/replay, Ghost steering, Devtools, @@ -99,10 +113,11 @@ explicit accepted `SurfacePlan`. - `packages/engine`, `packages/host`, `packages/devtools`, `packages/sandbox-runtime`, `packages/server`, `packages/react` - private implementation workspaces published only through the public facades. +- `examples/surface-gallery` - first-run live example app for OSS adopters. - `apps/server` - Anthropic-backed demo server, direction loading, repair feedback, and demo backing routes. -- `apps/demo` - Vite host app for generation, batch runs, adversarial checks, - strict input, and fatal sandbox testing. +- `apps/demo` - Vite workbench for generation, batch runs, adversarial checks, + strict input, Ghost steering, repair diagnostics, and fatal sandbox testing. ## Adoption Docs @@ -139,10 +154,12 @@ pnpm test:safety pnpm typecheck pnpm test pnpm test:safety +pnpm test:gallery pnpm build pnpm check:public-api pnpm smoke:public-packages pnpm pack:dry-run +pnpm dev:gallery pnpm dev:all pnpm port-direction [id] pnpm eval-directions [--prompts N] [--directions id,id] [--seed N] [--dry] diff --git a/docs/adoption/quickstart.md b/docs/adoption/quickstart.md index fa695a2..bae272f 100644 --- a/docs/adoption/quickstart.md +++ b/docs/adoption/quickstart.md @@ -1,8 +1,9 @@ # Summon Adoption Quickstart -This is the golden path for proving Summon works locally: generation, interactive -data resources, host state pushback, Devtools events, stream health, and sandbox -boundaries. +This is the golden path for proving Summon works locally. Start with the Surface +Gallery to see rich host-owned surfaces without workbench controls, then use the +Workbench to inspect generation, interactive data resources, host state +pushback, Devtools events, stream health, and sandbox boundaries. ## Prerequisites @@ -10,12 +11,28 @@ boundaries. - pnpm 10 or newer. - An Anthropic API key for `apps/server`. -## Run The Demo +## Run The Gallery ```sh pnpm install cp apps/server/.env.example apps/server/.env # edit apps/server/.env and set ANTHROPIC_API_KEY +pnpm dev:gallery +``` + +Open `http://localhost:5174`. + +The gallery is live-first. It requires `apps/server` and `ANTHROPIC_API_KEY`; +it does not silently fall back to replay. Use the preset cards to generate +static, host-resource, host-action, approval-gated, component-island, and +worker-backed surfaces. + +Each preset sends an explicit `SurfacePlan`, narrowed capability/component +contracts, and matching script policy to `/api/generate`. + +## Run The Workbench + +```sh pnpm dev:all ``` @@ -23,8 +40,8 @@ Open `http://localhost:5173/generate.html`. ## Golden Scenario -Use the **Host-resource search** scenario. The scenario is intentionally shaped -to exercise the adoption path: +In the workbench, use the **Host-resource search** scenario. The scenario is +intentionally shaped to exercise the adoption path: - `defineDataResource` via the demo `search` resource. - Loading, error, and data states through resource bindings. diff --git a/examples/surface-gallery/index.html b/examples/surface-gallery/index.html new file mode 100644 index 0000000..07e6078 --- /dev/null +++ b/examples/surface-gallery/index.html @@ -0,0 +1,70 @@ + + + + + +Summon Surface Gallery + + +
+ + +
+
+
+

Gallery

+

Static Brief

+

Choose a preset to see a focused Summon surface.

+
+ +
+ + + + +
+ +
+ Choose a preset, then generate. + Live generation streams into a null-origin iframe. +
+
+ +
+ Events 0 +
+
+
+ + +
+ + + + diff --git a/examples/surface-gallery/package.json b/examples/surface-gallery/package.json new file mode 100644 index 0000000..ff08c00 --- /dev/null +++ b/examples/surface-gallery/package.json @@ -0,0 +1,24 @@ +{ + "name": "@summon-example/surface-gallery", + "version": "0.0.1", + "private": true, + "type": "module", + "scripts": { + "dev": "pnpm --filter @summon-internal/devtools --filter @summon-internal/engine --filter @summon-internal/host --filter @summon-internal/sandbox-runtime build && pnpm --filter @anarchitecture/summon build && vite --host 127.0.0.1 --port 5174", + "build": "pnpm --filter @summon-internal/devtools --filter @summon-internal/engine --filter @summon-internal/host --filter @summon-internal/sandbox-runtime build && pnpm --filter @anarchitecture/summon build && vite build", + "test": "tsx --test src/*.test.ts", + "test:e2e": "playwright test -c playwright.config.ts", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@anarchitecture/summon": "workspace:*", + "zod": "^3.23.0" + }, + "devDependencies": { + "@playwright/test": "^1.60.0", + "@types/node": "^20.11.0", + "tsx": "^4.7.0", + "typescript": "^5.4.0", + "vite": "^5.4.0" + } +} diff --git a/examples/surface-gallery/playwright.config.ts b/examples/surface-gallery/playwright.config.ts new file mode 100644 index 0000000..afbdbe9 --- /dev/null +++ b/examples/surface-gallery/playwright.config.ts @@ -0,0 +1,27 @@ +import { defineConfig, devices } from '@playwright/test'; + +export default defineConfig({ + testDir: './tests', + timeout: 30_000, + expect: { + timeout: 10_000, + }, + reporter: process.env.CI ? [['list'], ['html', { open: 'never' }]] : 'list', + use: { + baseURL: 'http://127.0.0.1:5174', + trace: 'retain-on-failure', + screenshot: 'only-on-failure', + }, + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + ], + webServer: { + command: 'pnpm --filter @summon-example/surface-gallery dev', + url: 'http://127.0.0.1:5174', + reuseExistingServer: false, + timeout: 120_000, + }, +}); diff --git a/examples/surface-gallery/src/capabilities.ts b/examples/surface-gallery/src/capabilities.ts new file mode 100644 index 0000000..1c0a639 --- /dev/null +++ b/examples/surface-gallery/src/capabilities.ts @@ -0,0 +1,218 @@ +import { + createCapabilityRegistry, + defineAction, + defineApprovalAction, + defineDataResource, + defineWorkerAction, + defineWorkerResource, + type CapabilityDefinition, + type CapabilityRegistry, +} from '@anarchitecture/summon'; +import { z } from 'zod'; + +export interface GalleryCapabilityOptions { + onLog?: (message: string) => void; + onStatePreview?: (state: Record) => void; +} + +const chooseArgsSchema = z.object({ option: z.string().trim().min(1) }); +const publishArgsSchema = z.object({ title: z.string().trim().min(1) }); +const searchArgsSchema = z.object({ query: z.string().trim().min(1) }); +const analysisArgsSchema = z.object({ topic: z.string().trim().min(1) }); +const searchResultSchema = z.array( + z.object({ + title: z.string(), + snippet: z.string(), + source: z.string(), + }).passthrough(), +); +const analysisResultSchema = z.object({ + topic: z.string(), + score: z.number(), + summary: z.string(), + next: z.array(z.string()), +}); + +type SearchResult = z.infer; + +export function createGalleryCapabilityRegistry( + capabilityNames?: readonly string[], + opts: GalleryCapabilityOptions = {}, +): CapabilityRegistry { + const allowed = capabilityNames ? new Set(capabilityNames) : null; + return createCapabilityRegistry( + galleryCapabilityDefinitions(opts).filter((definition) => + allowed ? allowed.has(definition.name) : true, + ), + ); +} + +export function allGalleryCapabilityNames(): string[] { + return galleryCapabilityDefinitions({}).map((definition) => definition.name); +} + +function galleryCapabilityDefinitions(opts: GalleryCapabilityOptions): CapabilityDefinition[] { + const log = opts.onLog ?? (() => {}); + const statePreview = opts.onStatePreview ?? (() => {}); + const choices: string[] = []; + + return [ + defineDataResource({ + name: 'search', + description: + 'Run a host-owned text search and return 4-5 result objects. Use for recipe finders, docs search, product lookup, or any discovery surface. Render loading, error, and data states.', + argsSchema: searchArgsSchema, + resultSchema: searchResultSchema, + defaultData: [], + stateKeys: { loading: 'searching', data: 'results', error: 'searchError' }, + triggers: ['submit', 'mount'], + stateShape: + '{searching: boolean, query: string, results: Array<{title: string, snippet: string, source: string}> | null, searchError: string | null}', + patterns: [ + { + name: 'Search resource', + code: `
+
+ + +
+

Searching...

+

+
    + +
+
`, + }, + ], + onStart: ({ query }) => { + log(`search: ${query}`); + statePreview({ query }); + return { query }; + }, + onError: (message) => log(`search error: ${message}`), + fetch: async ({ query }, signal) => { + const response = await fetch('/api/mock-search', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ query }), + signal, + }); + if (!response.ok) throw new Error(`Search service unavailable (${response.status})`); + const body = (await response.json()) as { results?: unknown }; + const results = Array.isArray(body.results) ? body.results : []; + log(`search returned ${results.length} results`); + return results as SearchResult; + }, + }), + defineAction({ + name: 'choose', + description: + 'Save the option the user chose. Args must include an option label. Use for generated comparison, picker, or review surfaces.', + argsSchema: chooseArgsSchema, + stateShape: '{lastChoice: string, chosenOptions: string[]}', + patterns: [ + { + name: 'Save a choice', + code: ` +

Saved:

`, + }, + ], + handler: ({ args, push }) => { + choices.push(args.option); + log(`choose: ${args.option}`); + push({ lastChoice: args.option, chosenOptions: [...choices] }); + }, + }), + defineApprovalAction({ + name: 'publish_summary', + description: + 'Request host approval, then publish a titled summary only if the host approves. Use for publish, send, update, commit, or operate flows.', + argsSchema: publishArgsSchema, + stateShape: + '{published: boolean, publishedTitle: string | null, publishApprovalPending: boolean, publishApprovalApproved: boolean, publishApprovalDenied: boolean, publishApprovalError: string | null}', + approval: { + request: ({ title }) => { + log(`approval requested: ${title}`); + return window.confirm(`Approve publishing "${title}"?`) + ? 'approved' + : { status: 'denied', reason: 'Host denied approval' }; + }, + }, + handler: ({ args, push }) => { + log(`published: ${args.title}`); + push({ published: true, publishedTitle: args.title }); + }, + }), + defineWorkerResource({ + name: 'analysis', + description: + 'Run a host-owned background analysis for a topic. Use for risk, readiness, forecasting, scoring, or compute-style surfaces. Render loading, error, and result states.', + argsSchema: analysisArgsSchema, + resultSchema: analysisResultSchema, + defaultData: null, + stateKeys: { loading: 'analysisLoading', data: 'analysisResult', error: 'analysisError' }, + triggers: ['submit', 'mount'], + stateShape: + '{analysisLoading: boolean, analysisResult: {topic: string, score: number, summary: string, next: string[]} | null, analysisError: string | null}', + patterns: [ + { + name: 'Background analysis', + code: `
+
+ + +
+

Analyzing...

+
+ + +

+
+
`, + }, + ], + onStart: ({ topic }) => { + log(`analysis: ${topic}`); + return {}; + }, + fetch: async ({ topic }, signal) => { + await delay(350, signal); + const score = Math.max(12, Math.min(96, (topic.length * 11) % 101)); + return { + topic, + score, + summary: `Host worker analyzed "${topic}" and produced a ${score}/100 readiness signal.`, + next: ['Clarify the riskiest assumption', 'Pick one reversible next step', 'Review before operating'], + }; + }, + }), + defineWorkerAction({ + name: 'compute_score', + description: + 'Run a small host-owned worker calculation and push a computed score into state.', + argsSchema: analysisArgsSchema, + stateShape: '{computedTopic: string, computedScore: number}', + handler: async ({ args, push }) => { + await delay(180); + const computedScore = Math.max(1, Math.min(100, (args.topic.length * 9) % 101)); + log(`compute_score: ${args.topic} = ${computedScore}`); + push({ computedTopic: args.topic, computedScore }); + }, + }), + ]; +} + +function delay(ms: number, signal?: AbortSignal): Promise { + return new Promise((resolve, reject) => { + const timeout = window.setTimeout(resolve, ms); + signal?.addEventListener('abort', () => { + window.clearTimeout(timeout); + reject(new DOMException('Aborted', 'AbortError')); + }, { once: true }); + }); +} diff --git a/examples/surface-gallery/src/components.ts b/examples/surface-gallery/src/components.ts new file mode 100644 index 0000000..823cfbf --- /dev/null +++ b/examples/surface-gallery/src/components.ts @@ -0,0 +1,143 @@ +import { + createComponentRegistry, + defineComponent, + type ComponentDefinition, + type ComponentRegistry, +} from '@anarchitecture/summon'; +import { z } from 'zod'; + +const metricCardPropsSchema = z.object({ + label: z.string(), + value: z.string(), + delta: z.string().optional(), + tone: z.enum(['neutral', 'good', 'warn']).optional(), +}); + +const trendSparklinePropsSchema = z.object({ + label: z.string(), + points: z.array(z.number()).min(2).max(12), + caption: z.string().optional(), +}); + +const approvalStatusPropsSchema = z.object({ + status: z.enum(['pending', 'approved', 'blocked']), + title: z.string(), + detail: z.string().optional(), +}); + +type MetricCardProps = z.infer; +type TrendSparklineProps = z.infer; +type ApprovalStatusProps = z.infer; + +export function createGalleryComponentRegistry(componentNames?: readonly string[]): ComponentRegistry { + const allowed = componentNames ? new Set(componentNames) : null; + return createComponentRegistry( + galleryComponentDefinitions().filter((definition) => + allowed ? allowed.has(definition.name) : true, + ), + ); +} + +export function allGalleryComponentNames(): string[] { + return galleryComponentDefinitions().map((definition) => definition.name); +} + +function galleryComponentDefinitions(): ComponentDefinition[] { + return [ + defineComponent({ + name: 'MetricCard', + description: + 'Trusted host KPI card with label, value, optional delta, and tone. Use for readiness metrics, risk, progress, or launch quality.', + propsSchema: metricCardPropsSchema, + sizing: { height: '112px', description: 'Use in a compact dashboard grid.' }, + examples: [ + { + name: 'Metric card', + code: `
`, + }, + ], + render: ({ container, props }) => { + const tone = props.tone ?? 'neutral'; + const border = tone === 'warn' ? '#b45309' : tone === 'good' ? '#15803d' : '#d7d7d7'; + const bg = tone === 'warn' ? '#fff7ed' : tone === 'good' ? '#f0fdf4' : '#ffffff'; + const accent = tone === 'warn' ? '#b45309' : tone === 'good' ? '#15803d' : '#555555'; + container.innerHTML = ` +
+ ${esc(props.label)} + ${esc(props.value)} + ${props.delta ? `${esc(props.delta)}` : ''} +
`; + }, + }), + defineComponent({ + name: 'TrendSparkline', + description: + 'Trusted host trend line from numeric points. Use when the generated surface needs visual trend data.', + propsSchema: trendSparklinePropsSchema, + sizing: { height: '132px', description: 'Enough height for chart and caption.' }, + examples: [ + { + name: 'Trend sparkline', + code: `
`, + }, + ], + render: ({ container, props }) => { + const points = props.points.length >= 2 ? props.points : [0, 0]; + const min = Math.min(...points); + const max = Math.max(...points); + const spread = max - min || 1; + const d = points.map((point, index) => { + const x = 10 + (index / Math.max(points.length - 1, 1)) * 220; + const y = 82 - ((point - min) / spread) * 56; + return `${index === 0 ? 'M' : 'L'}${x.toFixed(1)} ${y.toFixed(1)}`; + }).join(' '); + container.innerHTML = ` +
+
${esc(props.label)}${points.length} pts
+ + + + + ${props.caption ? `

${esc(props.caption)}

` : ''} +
`; + }, + }), + defineComponent({ + name: 'ApprovalStatus', + description: + 'Trusted host status card for pending, approved, or blocked readiness gates.', + propsSchema: approvalStatusPropsSchema, + sizing: { height: '112px', description: 'Fits a compact status area.' }, + examples: [ + { + name: 'Approval status', + code: `
`, + }, + ], + render: ({ container, props }) => { + const colors = { + pending: ['#fff7ed', '#b45309', 'Pending'], + approved: ['#f0fdf4', '#15803d', 'Approved'], + blocked: ['#fff1f2', '#be123c', 'Blocked'], + } as const; + const [bg, fg, label] = colors[props.status]; + container.innerHTML = ` +
+ ${label} + ${esc(props.title)} + ${props.detail ? `

${esc(props.detail)}

` : ''} +
`; + }, + }), + ]; +} + +function esc(value: unknown): string { + return String(value ?? '').replace(/[&<>"']/g, (char) => ({ + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''', + }[char] ?? char)); +} diff --git a/examples/surface-gallery/src/main.ts b/examples/surface-gallery/src/main.ts new file mode 100644 index 0000000..c9b3f08 --- /dev/null +++ b/examples/surface-gallery/src/main.ts @@ -0,0 +1,384 @@ +import { + PolicyEngine, + deriveSurfacePlanControls, + type ComponentPack, + type SurfacePlan, +} from '@anarchitecture/summon'; +import { + consumeSurfaceStream, + createComponentIslandRegistry, + spawnSandbox, + type ComponentIslandRegistry, + type SandboxHandle, + type SurfaceStreamContext, +} from '@anarchitecture/summon/browser'; +import { createEventStore, type DevtoolsEvent } from '@anarchitecture/summon/devtools'; +import { SectionAccumulator, type ProtocolLine } from '@anarchitecture/summon/engine'; +import { bootstrapSource, tokensSource } from '@anarchitecture/summon/assets'; +import { createGalleryCapabilityRegistry } from './capabilities.js'; +import { + allGalleryComponentNames, + createGalleryComponentRegistry, +} from './components.js'; +import { + GALLERY_PRESETS, + findPreset, + planText, + type GalleryPreset, +} from './presets.js'; +import './styles.css'; + +const presetList = document.getElementById('preset-list')!; +const presetCategory = document.getElementById('preset-category')!; +const presetTitle = document.getElementById('preset-title')!; +const presetDescription = document.getElementById('preset-description')!; +const promptEl = document.getElementById('prompt') as HTMLTextAreaElement; +const runButton = document.getElementById('run') as HTMLButtonElement; +const iframe = document.getElementById('sandbox') as HTMLIFrameElement; +const welcome = document.getElementById('welcome')!; +const contractSummary = document.getElementById('contract-summary')!; +const statusEl = document.getElementById('status')!; +const acceptedCountEl = document.getElementById('accepted-count')!; +const skippedCountEl = document.getElementById('skipped-count')!; +const blockedCountEl = document.getElementById('blocked-count')!; +const statePreview = document.getElementById('state-preview')!; +const setupNote = document.getElementById('setup-note')!; +const eventCount = document.getElementById('event-count')!; +const eventLog = document.getElementById('event-log')!; + +const events = createEventStore({ bufferSize: 120 }); +const accumulator = new SectionAccumulator(); +const hostMessages: string[] = []; + +let selectedPreset = GALLERY_PRESETS[0]!; +let handle: SandboxHandle | null = null; +let islands: ComponentIslandRegistry | null = null; +let policy: PolicyEngine | null = null; +let abortController: AbortController | null = null; +let acceptedStructuralLines = 0; +let skippedLines = 0; +let blockedLines = 0; + +events.subscribe(renderEvents); + +renderPresetCards(); +selectPreset(selectedPreset.id); + +runButton.addEventListener('click', () => { + void generateSelectedSurface(); +}); + +function renderPresetCards(): void { + presetList.innerHTML = ''; + for (const preset of GALLERY_PRESETS) { + const button = document.createElement('button'); + button.type = 'button'; + button.className = 'preset-card'; + button.dataset.presetId = preset.id; + button.innerHTML = ` + ${preset.category} + ${preset.title} + ${planText(preset.surfacePlan)} + ${preset.description} + `; + button.addEventListener('click', () => selectPreset(preset.id)); + presetList.append(button); + } +} + +function selectPreset(id: string): void { + selectedPreset = findPreset(id); + for (const card of presetList.querySelectorAll('.preset-card')) { + card.classList.toggle('active', card.dataset.presetId === selectedPreset.id); + } + presetCategory.textContent = selectedPreset.category; + presetTitle.textContent = selectedPreset.title; + presetDescription.textContent = selectedPreset.description; + promptEl.value = selectedPreset.prompt; + resetCounters(); + respawnSandbox(); + renderContract(); + renderHealth('idle'); + setSetupNote(null); + welcome.classList.remove('hidden'); +} + +function respawnSandbox(): void { + islands?.destroy(); + islands = null; + handle?.dispose(); + handle = null; + policy = null; + + const capabilityRegistry = createGalleryCapabilityRegistry(selectedPreset.capabilityNames, { + onLog: pushHostMessage, + }); + const capabilityContract = capabilityRegistry.toContract(); + const componentRegistry = selectedPreset.componentNames?.length + ? createGalleryComponentRegistry(selectedPreset.componentNames) + : null; + const componentContract = componentRegistry?.toContract(); + if (componentRegistry) { + islands = createComponentIslandRegistry({ + outerIframe: iframe, + registry: componentRegistry, + events, + onError: (error) => { + pushHostMessage(`component ${error.code}: ${error.reason}`); + }, + }); + } + + const initialState = selectedPreset.mode === 'interactive' + ? capabilityContract.initialState + : {}; + renderState(initialState); + + if (selectedPreset.mode === 'interactive') { + policy = new PolicyEngine({ + initialState, + handlers: capabilityRegistry.toPolicyHandlers(), + events, + onStateChange: (state) => { + renderState(state); + handle?.pushState(state); + }, + onHandlerError: (intent, error) => { + pushHostMessage(`handler ${intent}: ${error.message}`); + }, + }); + } + + handle = spawnSandbox({ + iframe, + artifact: { + html: '', + intents: [], + capabilities: capabilityContract.validationCapabilities, + components: componentContract?.validationComponents, + initialState, + }, + grantedIntents: selectedPreset.mode === 'interactive' ? capabilityRegistry.intents() : [], + grantedCapabilities: selectedPreset.mode === 'interactive' + ? capabilityContract.validationCapabilities + : [], + bootstrapSource, + tokensSource, + events, + onIntent: (intent, args) => { + void policy?.dispatch(intent, args); + }, + onComponents: (components, sandboxId) => { + islands?.sync(components, { + sandboxId, + emitIntent: (intent, args = {}) => { + void policy?.dispatch(intent, args); + }, + }); + }, + onSandboxFatal: (reason) => { + setSetupNote(`Sandbox failed to boot: ${reason}`); + }, + }); +} + +async function generateSelectedSurface(): Promise { + abortController?.abort(); + abortController = new AbortController(); + accumulator.reset(); + events.clear(); + hostMessages.length = 0; + resetCounters(); + respawnSandbox(); + welcome.classList.add('hidden'); + runButton.disabled = true; + renderHealth('streaming'); + setSetupNote(null); + events.push({ kind: 'stream-lifecycle', at: Date.now(), phase: 'start' }); + + const capabilityPack = selectedPreset.mode === 'interactive' + ? createGalleryCapabilityRegistry(selectedPreset.capabilityNames).toContract().pack + : null; + const componentPack = componentPackFor(selectedPreset); + const controls = deriveSurfacePlanControls(selectedPreset.surfacePlan); + + try { + const response = await fetch('/api/generate', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + signal: abortController.signal, + body: JSON.stringify({ + prompt: promptEl.value.trim(), + mode: selectedPreset.mode, + surfacePlan: selectedPreset.surfacePlan, + surfaceCeiling: selectedPreset.surfaceCeiling, + scriptPolicy: selectedPreset.scriptPolicy, + capabilities: capabilityPack, + ...(componentPack ? { components: componentPack } : {}), + }), + }); + + if (!response.ok || !response.body) { + const text = await response.text().catch(() => ''); + throw new Error(text || `Generation server returned ${response.status}`); + } + + await consumeSurfaceStream(response.body, { + mode: controls.mode, + accumulator, + onLine: (line, context) => handleLine(line, context), + onMeta: (line) => handleMeta(line), + onRenderHtml: (html) => { + handle?.render(html); + }, + onParseError: (raw) => { + events.push({ kind: 'protocol-parse-error', at: Date.now(), raw }); + }, + }); + events.push({ kind: 'stream-lifecycle', at: Date.now(), phase: 'end', ok: true }); + renderHealth('done'); + } catch (error) { + if (error instanceof DOMException && error.name === 'AbortError') return; + const message = error instanceof Error ? error.message : String(error); + renderHealth('setup needed'); + events.push({ kind: 'stream-lifecycle', at: Date.now(), phase: 'end', ok: false }); + setSetupNote( + `Live generation needs the demo server. Run pnpm dev:gallery and set ANTHROPIC_API_KEY in apps/server/.env. ${message}`, + ); + } finally { + runButton.disabled = false; + } +} + +function componentPackFor(preset: GalleryPreset): ComponentPack | null { + if (!preset.componentNames?.length) return null; + const allowed = new Set(preset.componentNames); + const pack = createGalleryComponentRegistry().toContract().pack; + return { + components: pack.components.filter((component) => allowed.has(component.name)), + }; +} + +function handleLine(line: ProtocolLine, context: SurfaceStreamContext): void { + if (line.op !== 'meta') { + acceptedStructuralLines = context.acceptedStructuralLines; + acceptedCountEl.textContent = String(acceptedStructuralLines); + } + events.push({ kind: 'protocol-line', at: Date.now(), line }); +} + +function handleMeta(line: Extract): void { + if (line.path === '/status') { + renderHealth(String(line.value)); + } + if (line.path === '/protocol-skip') { + skippedLines += 1; + } + if (line.path === '/validation-blocked') { + blockedLines += 1; + } + if (line.path === '/stream-graph-summary') { + const value = line.value as { health?: { blockedCount?: unknown; skippedCount?: unknown } }; + if (typeof value.health?.blockedCount === 'number') blockedLines = value.health.blockedCount; + if (typeof value.health?.skippedCount === 'number') skippedLines = value.health.skippedCount; + } + skippedCountEl.textContent = String(skippedLines); + blockedCountEl.textContent = String(blockedLines); +} + +function renderContract(): void { + const components = selectedPreset.componentNames?.length + ? selectedPreset.componentNames.join(', ') + : 'none'; + const grants = selectedPreset.capabilityNames.length + ? selectedPreset.capabilityNames.join(', ') + : 'none'; + contractSummary.innerHTML = ''; + const rows: Array<[string, string]> = [ + ['Plan', planText(selectedPreset.surfacePlan)], + ['Mode', selectedPreset.mode], + ['Scripts', selectedPreset.scriptPolicy], + ['Grants', grants], + ['Components', components], + ]; + for (const [label, value] of rows) { + const row = document.createElement('div'); + row.className = 'contract-row'; + row.dataset.contractRow = label.toLowerCase(); + row.innerHTML = `${label}${value}`; + contractSummary.append(row); + } +} + +function renderHealth(status: string): void { + statusEl.textContent = status; + acceptedCountEl.textContent = String(acceptedStructuralLines); + skippedCountEl.textContent = String(skippedLines); + blockedCountEl.textContent = String(blockedLines); +} + +function renderState(state: Record): void { + statePreview.textContent = JSON.stringify(state, null, 2); +} + +function renderEvents(): void { + const rows = [ + ...events.snapshot().map(describeEvent), + ...hostMessages, + ].slice(-10); + eventCount.textContent = String(events.size() + hostMessages.length); + eventLog.innerHTML = ''; + for (const event of rows) { + const row = document.createElement('div'); + row.className = 'event-row'; + row.textContent = event; + eventLog.append(row); + } +} + +function describeEvent(event: DevtoolsEvent): string { + switch (event.kind) { + case 'protocol-line': + return `protocol ${event.line.op} ${event.line.path}`; + case 'intent-emitted': + return `intent ${event.intent}`; + case 'intent-dispatched': + return `dispatch ${event.intent}`; + case 'intent-settled': + return `settled ${event.intent} ${event.ok ? 'ok' : 'error'}`; + case 'state-pushed': + return `state ${Object.keys(event.patch).join(', ') || 'updated'}`; + case 'component-error': + return `component ${event.code}: ${event.reason}`; + case 'stream-lifecycle': + return `stream ${event.phase}${event.ok === undefined ? '' : event.ok ? ' ok' : ' error'}`; + default: + return event.kind; + } +} + +function pushHostMessage(message: string): void { + hostMessages.push(message); + if (hostMessages.length > 30) hostMessages.splice(0, hostMessages.length - 30); + renderEvents(); +} + +function resetCounters(): void { + acceptedStructuralLines = 0; + skippedLines = 0; + blockedLines = 0; + acceptedCountEl.textContent = '0'; + skippedCountEl.textContent = '0'; + blockedCountEl.textContent = '0'; +} + +function setSetupNote(message: string | null): void { + setupNote.hidden = !message; + setupNote.textContent = message ?? ''; +} + +export const galleryTestApi = { + presets: GALLERY_PRESETS, + allComponentNames: allGalleryComponentNames, + selectPreset, +}; diff --git a/examples/surface-gallery/src/presets.test.ts b/examples/surface-gallery/src/presets.test.ts new file mode 100644 index 0000000..535cc2c --- /dev/null +++ b/examples/surface-gallery/src/presets.test.ts @@ -0,0 +1,47 @@ +import assert from 'node:assert/strict'; +import { readdirSync, readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import test from 'node:test'; +import { + deriveSurfacePlanControls, + normalizeSurfacePlan, + surfacePlanWithinCeiling, +} from '@anarchitecture/summon'; +import { allGalleryCapabilityNames } from './capabilities.js'; +import { allGalleryComponentNames } from './components.js'; +import { GALLERY_PRESETS } from './presets.js'; + +test('gallery presets are explicit, valid, and contract-complete', () => { + const capabilityNames = new Set(allGalleryCapabilityNames()); + const componentNames = new Set(allGalleryComponentNames()); + const seen = new Set(); + + assert.equal(GALLERY_PRESETS.length, 6); + + for (const preset of GALLERY_PRESETS) { + assert.equal(seen.has(preset.id), false, `duplicate preset ${preset.id}`); + seen.add(preset.id); + assert.deepEqual(normalizeSurfacePlan(preset.surfacePlan), preset.surfacePlan); + assert.equal(surfacePlanWithinCeiling(preset.surfacePlan, preset.surfaceCeiling), true); + assert.equal(preset.scriptPolicy, deriveSurfacePlanControls(preset.surfacePlan).scriptPolicy); + + for (const capability of preset.capabilityNames) { + assert.equal(capabilityNames.has(capability), true, `${preset.id} references unknown capability ${capability}`); + } + for (const component of preset.componentNames ?? []) { + assert.equal(componentNames.has(component), true, `${preset.id} references unknown component ${component}`); + } + } +}); + +test('surface gallery source imports public Summon packages only', () => { + const sourceDir = dirname(fileURLToPath(import.meta.url)); + for (const entry of readdirSync(sourceDir)) { + if (!entry.endsWith('.ts')) continue; + if (entry.endsWith('.test.ts')) continue; + const text = readFileSync(join(sourceDir, entry), 'utf8'); + assert.equal(text.includes('@summon-internal/'), false, `${entry} imports an internal package`); + assert.equal(/from ['"]@anarchitecture\/summon\/(host|engine\/src|browser\/src)/.test(text), false); + } +}); diff --git a/examples/surface-gallery/src/presets.ts b/examples/surface-gallery/src/presets.ts new file mode 100644 index 0000000..d5952fa --- /dev/null +++ b/examples/surface-gallery/src/presets.ts @@ -0,0 +1,153 @@ +import { + deriveSurfacePlanControls, + type ScriptPolicy, + type SurfaceCeiling, + type SurfacePlan, + type SurfacePlanMode, +} from '@anarchitecture/summon'; + +export interface GalleryPreset { + id: string; + title: string; + category: string; + description: string; + prompt: string; + mode: SurfacePlanMode; + surfacePlan: SurfacePlan; + scriptPolicy: ScriptPolicy; + capabilityNames: string[]; + componentNames?: string[]; + surfaceCeiling: SurfaceCeiling; +} + +function ceilingFor(plan: SurfacePlan): SurfaceCeiling { + return { + purposes: [plan.purpose], + runtimes: [plan.runtime], + data: [plan.data], + authorities: [plan.authority], + persistences: [plan.persistence], + }; +} + +function preset(input: Omit): GalleryPreset { + return { + ...input, + scriptPolicy: deriveSurfacePlanControls(input.surfacePlan).scriptPolicy, + surfaceCeiling: ceilingFor(input.surfacePlan), + }; +} + +export const GALLERY_PRESETS: GalleryPreset[] = [ + preset({ + id: 'static-brief', + title: 'Static Brief', + category: 'Read-only', + description: 'A rich generated brief with embedded data and no executable authority.', + prompt: + 'compare renting versus buying a small coffee cart for a first-time weekend pop-up operator. Make it concrete, skimmable, and decision-oriented.', + mode: 'static', + capabilityNames: [], + surfacePlan: { + purpose: 'compare', + runtime: 'static', + data: 'embedded', + authority: 'none', + persistence: 'replayable', + }, + }), + preset({ + id: 'search-explorer', + title: 'Search Explorer', + category: 'Host data', + description: 'Generated search UI reads through a host-owned data resource.', + prompt: + 'build a weeknight dinner explorer where i can search for recipes, see loading and error states, browse results, and pick one to inspect.', + mode: 'interactive', + capabilityNames: ['search'], + surfacePlan: { + purpose: 'explore', + runtime: 'declarative', + data: 'host-resource', + authority: 'read', + persistence: 'replayable', + }, + }), + preset({ + id: 'decision-picker', + title: 'Decision Picker', + category: 'Host action', + description: 'A comparison surface can save a choice through a host-granted action.', + prompt: + 'help me choose between three launch announcement approaches for a small developer tool. Compare tradeoffs and let me save the best option.', + mode: 'interactive', + capabilityNames: ['choose'], + surfacePlan: { + purpose: 'compare', + runtime: 'declarative', + data: 'embedded', + authority: 'host-action', + persistence: 'replayable', + }, + }), + preset({ + id: 'approval-flow', + title: 'Approval Flow', + category: 'Approval', + description: 'Generated publish UI can request approval, but the host owns the decision.', + prompt: + 'build a publish review panel for a product update summary. Make the draft easy to review and include one approval-gated publish action.', + mode: 'interactive', + capabilityNames: ['publish_summary'], + surfacePlan: { + purpose: 'operate', + runtime: 'declarative', + data: 'embedded', + authority: 'approval-gated', + persistence: 'replayable', + }, + }), + preset({ + id: 'component-island-dashboard', + title: 'Component Island Dashboard', + category: 'Trusted components', + description: 'The model authors layout; trusted host components render outside the iframe.', + prompt: + 'build a compact launch readiness dashboard. Use host-rendered MetricCard, TrendSparkline, and ApprovalStatus components for the key signals, then write the surrounding interpretation and actions.', + mode: 'interactive', + capabilityNames: ['choose'], + componentNames: ['MetricCard', 'TrendSparkline', 'ApprovalStatus'], + surfacePlan: { + purpose: 'review', + runtime: 'declarative', + data: 'embedded', + authority: 'host-action', + persistence: 'replayable', + }, + }), + preset({ + id: 'worker-analysis', + title: 'Worker Analysis', + category: 'Background work', + description: 'Host-owned worker-style resources compute data and push safe state back.', + prompt: + 'create a risk analysis surface for launching a paid beta next month. Let me run a background readiness analysis and compute a small score.', + mode: 'interactive', + capabilityNames: ['analysis', 'compute_score'], + surfacePlan: { + purpose: 'review', + runtime: 'worker', + data: 'worker', + authority: 'host-action', + persistence: 'replayable', + }, + }), +]; + +export function findPreset(id: string): GalleryPreset { + return GALLERY_PRESETS.find((preset) => preset.id === id) ?? GALLERY_PRESETS[0]!; +} + +export function planText(plan: SurfacePlan): string { + return `${plan.purpose}/${plan.runtime}/${plan.data}/${plan.authority}/${plan.persistence}`; +} diff --git a/examples/surface-gallery/src/styles.css b/examples/surface-gallery/src/styles.css new file mode 100644 index 0000000..9850494 --- /dev/null +++ b/examples/surface-gallery/src/styles.css @@ -0,0 +1,459 @@ +:root { + color-scheme: light; + --bg: #f7f7f4; + --panel: #ffffff; + --ink: #161616; + --muted: #666b73; + --line: #deded8; + --line-strong: #222222; + --blue: #2457d6; + --green: #167a3a; + --amber: #a15c00; + --red: #b4233a; + --shadow: 0 18px 60px rgba(15, 23, 42, 0.10); + font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + min-width: 320px; + background: var(--bg); + color: var(--ink); +} + +button, +textarea { + font: inherit; +} + +.gallery-shell { + min-height: 100vh; + display: grid; + grid-template-columns: 320px minmax(0, 1fr) 300px; +} + +.preset-rail, +.contract-panel { + background: var(--panel); + border-right: 1px solid var(--line); + padding: 24px; +} + +.contract-panel { + border-right: 0; + border-left: 1px solid var(--line); +} + +.wordmark { + color: var(--ink); + text-decoration: none; + font-weight: 800; + letter-spacing: 0; + display: inline-flex; + margin-bottom: 28px; +} + +.rail-intro h1, +.stage-header h2 { + margin: 0; + letter-spacing: 0; + line-height: 1.04; +} + +.rail-intro h1 { + font-size: 27px; + max-width: 260px; +} + +.stage-header h2 { + font-size: 30px; +} + +.eyebrow, +.prompt-label { + margin: 0 0 8px; + color: var(--muted); + font-size: 11px; + font-weight: 800; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.preset-list { + display: grid; + gap: 10px; + margin-top: 24px; +} + +.preset-card { + width: 100%; + border: 1px solid var(--line); + background: #fbfbf8; + color: var(--ink); + border-radius: 8px; + padding: 14px; + text-align: left; + display: grid; + gap: 6px; + cursor: pointer; +} + +.preset-card:hover, +.preset-card.active { + border-color: var(--line-strong); + background: #ffffff; +} + +.preset-card span { + color: var(--blue); + font-size: 11px; + font-weight: 800; + letter-spacing: 0.06em; + text-transform: uppercase; +} + +.preset-card strong { + font-size: 16px; +} + +.preset-card em { + color: var(--muted); + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 11px; + font-style: normal; +} + +.preset-card small { + color: var(--muted); + font-size: 13px; + line-height: 1.35; +} + +.surface-stage { + padding: 28px; + min-width: 0; + display: grid; + grid-template-rows: auto auto minmax(360px, 1fr) auto; + gap: 16px; +} + +.stage-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 20px; +} + +.stage-header p:last-child { + margin: 8px 0 0; + color: var(--muted); + max-width: 620px; +} + +#run { + border: 1px solid var(--ink); + border-radius: 8px; + color: white; + background: var(--ink); + padding: 10px 16px; + font-weight: 750; + cursor: pointer; +} + +#run:disabled { + opacity: 0.55; + cursor: wait; +} + +.prompt-box { + width: 100%; + min-height: 92px; + resize: vertical; + border: 1px solid var(--line); + border-radius: 8px; + background: white; + color: var(--ink); + padding: 14px; + line-height: 1.45; +} + +.sandbox-frame { + position: relative; + overflow: hidden; + min-height: 440px; + border: 1px solid var(--line); + border-radius: 8px; + background: white; + box-shadow: var(--shadow); +} + +#sandbox { + width: 100%; + height: 100%; + min-height: 560px; + border: 0; + display: block; +} + +.welcome { + position: absolute; + inset: 0; + display: grid; + place-content: center; + gap: 8px; + text-align: center; + background: #ffffff; + color: var(--muted); +} + +.welcome strong { + color: var(--ink); + font-size: 18px; +} + +.welcome.hidden { + display: none; +} + +.event-strip { + border: 1px solid var(--line); + border-radius: 8px; + background: white; + overflow: hidden; +} + +.event-strip summary { + cursor: pointer; + padding: 12px 14px; + font-weight: 750; +} + +.event-strip summary span { + color: var(--muted); + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-weight: 500; +} + +.event-log { + display: grid; + gap: 1px; + max-height: 180px; + overflow: auto; + border-top: 1px solid var(--line); + background: var(--line); +} + +.event-row { + background: white; + padding: 9px 14px; + color: var(--muted); + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 12px; +} + +.contract-panel { + display: grid; + align-content: start; + gap: 18px; +} + +.panel-block { + display: grid; + gap: 10px; +} + +.contract-summary { + display: grid; + border: 1px solid var(--line); + border-radius: 8px; + overflow: hidden; +} + +.contract-row { + display: grid; + grid-template-columns: 82px minmax(0, 1fr); + gap: 10px; + padding: 10px 12px; + border-bottom: 1px solid var(--line); +} + +.contract-row:last-child { + border-bottom: 0; +} + +.contract-row span, +.health-grid span { + color: var(--muted); + font-size: 12px; +} + +.contract-row strong { + min-width: 0; + overflow-wrap: anywhere; + font-size: 12px; +} + +.health-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 8px; +} + +.health-grid div { + border: 1px solid var(--line); + border-radius: 8px; + padding: 10px; + display: grid; + gap: 4px; +} + +.health-grid strong { + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 13px; +} + +#state-preview { + margin: 0; + max-height: 220px; + overflow: auto; + border: 1px solid var(--line); + border-radius: 8px; + background: #fbfbf8; + padding: 12px; + color: #2d3748; + font-size: 12px; + line-height: 1.45; +} + +.setup-note { + border: 1px solid var(--amber); + border-radius: 8px; + background: #fff7ed; + color: #7c2d12; + padding: 12px; + font-size: 13px; + line-height: 1.4; +} + +.host-metric, +.host-trend, +.host-approval { + height: 100%; + border: 1px solid; + border-radius: 8px; + padding: 14px; + font-family: Inter, ui-sans-serif, system-ui, sans-serif; +} + +.host-metric { + display: grid; + align-content: space-between; +} + +.host-metric span { + color: #4b5563; + font-size: 11px; + font-weight: 800; + text-transform: uppercase; + letter-spacing: 0.06em; +} + +.host-metric strong { + font-size: 32px; + line-height: 1; +} + +.host-metric em { + font-size: 13px; + font-style: normal; + font-weight: 800; +} + +.host-trend { + border-color: #d7d7d7; + background: white; +} + +.host-trend div { + display: flex; + justify-content: space-between; + gap: 12px; +} + +.host-trend span, +.host-trend p { + color: #6b7280; + font-size: 12px; +} + +.host-trend svg { + display: block; + width: 100%; + height: 72px; +} + +.host-approval { + display: grid; + align-content: start; + gap: 8px; + color: #111827; +} + +.host-approval span { + width: max-content; + color: white; + border-radius: 999px; + padding: 3px 8px; + font-size: 11px; + font-weight: 850; + text-transform: uppercase; +} + +.host-approval p { + margin: 0; + color: #4b5563; + font-size: 12px; +} + +@media (max-width: 1100px) { + .gallery-shell { + grid-template-columns: 280px minmax(0, 1fr); + } + + .contract-panel { + grid-column: 1 / -1; + border-left: 0; + border-top: 1px solid var(--line); + grid-template-columns: repeat(3, minmax(0, 1fr)); + } +} + +@media (max-width: 760px) { + .gallery-shell { + display: block; + } + + .preset-rail, + .surface-stage, + .contract-panel { + border: 0; + padding: 18px; + } + + .contract-panel { + display: grid; + grid-template-columns: 1fr; + } + + .stage-header { + display: grid; + } + + #run { + width: 100%; + } +} diff --git a/examples/surface-gallery/src/vite-env.d.ts b/examples/surface-gallery/src/vite-env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/examples/surface-gallery/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/examples/surface-gallery/tests/gallery-smoke.spec.ts b/examples/surface-gallery/tests/gallery-smoke.spec.ts new file mode 100644 index 0000000..ff925b8 --- /dev/null +++ b/examples/surface-gallery/tests/gallery-smoke.spec.ts @@ -0,0 +1,133 @@ +import { expect, test } from '@playwright/test'; + +function streamBody(lines: unknown[]): string { + return `${lines.map((line) => JSON.stringify(line)).join('\n')}\n`; +} + +test('gallery boots and preset selection updates the contract panel', async ({ page }) => { + await page.goto('/'); + + await expect(page.locator('[data-preset-id]')).toHaveCount(6); + await expect(page.locator('#preset-title')).toContainText('Static Brief'); + await expect(page.locator('[data-contract-row="plan"]')).toContainText('compare/static/embedded/none/replayable'); + + await page.locator('[data-preset-id="search-explorer"]').click(); + await expect(page.locator('#preset-title')).toContainText('Search Explorer'); + await expect(page.locator('#prompt')).toHaveValue(/weeknight dinner explorer/); + await expect(page.locator('[data-contract-row="plan"]')).toContainText('explore/declarative/host-resource/read/replayable'); + await expect(page.locator('[data-contract-row="grants"]')).toContainText('search'); +}); + +test('mocked generation renders and generated intents update host state', async ({ page }) => { + let captured: any = null; + await page.route('**/api/generate', async (route) => { + captured = route.request().postDataJSON(); + await route.fulfill({ + status: 200, + contentType: 'text/plain', + body: streamBody([ + { op: 'meta', path: '/surface-plan', value: captured.surfacePlan }, + { op: 'meta', path: '/status', value: 'writing' }, + { op: 'set', path: '/screen', value: { sections: ['main'] } }, + { + op: 'add', + path: '/section/main', + html: ` +
+

Pick a launch path

+ +

Saved

+
`, + }, + { + op: 'meta', + path: '/stream-graph-summary', + value: { + health: { + complete: true, + missingDeclared: [], + blockedCount: 0, + skippedCount: 0, + repairedCount: 0, + }, + sections: [], + }, + }, + ]), + }); + }); + + await page.goto('/'); + await page.locator('[data-preset-id="decision-picker"]').click(); + await page.locator('#run').click(); + await expect(page.locator('#status')).toContainText('done'); + + expect(captured.mode).toBe('interactive'); + expect(captured.scriptPolicy).toBe('forbid'); + expect(captured.capabilities.intents.map((intent: any) => intent.name)).toEqual(['choose']); + expect(captured.components).toBeUndefined(); + + const frame = page.frameLocator('#sandbox'); + await frame.locator('button').click(); + await expect(page.locator('#state-preview')).toContainText('Balanced path'); + await expect(page.locator('#event-log')).toContainText('intent choose'); +}); + +test('component island preset renders host overlays and reports invalid props', async ({ page }) => { + let invalid = false; + const requests: any[] = []; + + await page.route('**/api/generate', async (route) => { + const captured = route.request().postDataJSON(); + requests.push(captured); + const html = invalid + ? `
+
+
` + : `
+
+
+
`; + + await route.fulfill({ + status: 200, + contentType: 'text/plain', + body: streamBody([ + { op: 'meta', path: '/surface-plan', value: captured.surfacePlan }, + { op: 'set', path: '/screen', value: { sections: ['main'] } }, + { op: 'add', path: '/section/main', html }, + { + op: 'meta', + path: '/stream-graph-summary', + value: { + health: { + complete: true, + missingDeclared: [], + blockedCount: 0, + skippedCount: 0, + repairedCount: 0, + }, + sections: [], + }, + }, + ]), + }); + }); + + await page.goto('/'); + await page.locator('[data-preset-id="component-island-dashboard"]').click(); + await page.locator('#run').click(); + await expect(page.locator('[data-summon-component-id="launch-score"]')).toContainText('Launch score'); + await expect(page.locator('[data-summon-component-id="quality-trend"]')).toContainText('Quality trend'); + + expect(requests[0].components.components.map((component: any) => component.name)).toEqual([ + 'MetricCard', + 'TrendSparkline', + 'ApprovalStatus', + ]); + + invalid = true; + await page.locator('#run').click(); + await expect(page.locator('[data-summon-component-id="bad-props"]')).toHaveCount(0); + await expect(page.locator('#event-log')).toContainText('component props-invalid'); +}); diff --git a/examples/surface-gallery/tsconfig.json b/examples/surface-gallery/tsconfig.json new file mode 100644 index 0000000..bdf6349 --- /dev/null +++ b/examples/surface-gallery/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "types": ["vite/client", "node"] + }, + "include": ["src", "tests", "vite.config.ts", "playwright.config.ts"] +} diff --git a/examples/surface-gallery/vite.config.ts b/examples/surface-gallery/vite.config.ts new file mode 100644 index 0000000..473a06c --- /dev/null +++ b/examples/surface-gallery/vite.config.ts @@ -0,0 +1,13 @@ +import { defineConfig } from 'vite'; + +export default defineConfig({ + server: { + port: 5174, + proxy: { + '/api': { + target: 'http://localhost:3001', + changeOrigin: true, + }, + }, + }, +}); diff --git a/package.json b/package.json index aa432ea..b03ed5d 100644 --- a/package.json +++ b/package.json @@ -7,20 +7,23 @@ "dev": "pnpm --filter @summon-internal/demo dev", "dev:server": "pnpm --filter @summon-internal/demo-server dev", "dev:all": "pnpm -r --parallel --stream --filter @summon-internal/demo-server --filter @summon-internal/demo dev", + "dev:gallery": "pnpm -r --parallel --stream --filter @summon-internal/demo-server --filter @summon-example/surface-gallery dev", "build:impl": "pnpm -r --filter @summon-internal/devtools --filter @summon-internal/engine --filter @summon-internal/sandbox-runtime --filter @summon-internal/host --filter @summon-internal/server build", "build:react-impl": "pnpm --filter @summon-internal/react build", "build:public-core": "node scripts/build-public-packages.mjs summon summon-server", "build:public": "node scripts/build-public-packages.mjs", "build:apps": "pnpm --filter @summon-internal/demo build", - "build": "pnpm build:impl && pnpm build:public-core && pnpm build:react-impl && pnpm build:public && pnpm build:apps", + "build:examples": "pnpm --filter @summon-example/surface-gallery build", + "build": "pnpm build:impl && pnpm build:public-core && pnpm build:react-impl && pnpm build:public && pnpm build:apps && pnpm build:examples", "check:public-packages": "node scripts/check-public-packages.mjs", "check:public-api": "node scripts/check-public-api.mjs", - "check:release": "pnpm typecheck && pnpm test && pnpm build && pnpm check:public-packages && pnpm check:public-api && pnpm pack:dry-run && pnpm smoke:public-packages && pnpm test:safety", + "check:release": "pnpm typecheck && pnpm test && pnpm build && pnpm check:public-packages && pnpm check:public-api && pnpm pack:dry-run && pnpm smoke:public-packages && pnpm test:gallery && pnpm test:safety", "pack:dry-run": "node scripts/pack-public-packages.mjs --dry-run", "smoke:public-packages": "node scripts/smoke-public-packages.mjs", - "test": "pnpm -r --filter @summon-internal/engine --filter @summon-internal/host --filter @summon-internal/server --filter @summon-internal/demo-server --filter @summon-internal/demo test", + "test": "pnpm -r --filter @summon-internal/engine --filter @summon-internal/host --filter @summon-internal/server --filter @summon-internal/demo-server --filter @summon-internal/demo --filter @summon-example/surface-gallery test", "test:safety": "playwright test", "test:safety:ui": "playwright test --ui", + "test:gallery": "pnpm --filter @summon-example/surface-gallery test:e2e", "typecheck": "pnpm -r typecheck", "changeset": "changeset", "version-packages": "changeset version", diff --git a/playwright.config.ts b/playwright.config.ts index 390471a..b225186 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -1,5 +1,8 @@ import { defineConfig, devices } from '@playwright/test'; +const safetyPort = Number(process.env.SUMMON_SAFETY_PORT ?? 5173); +const safetyBaseUrl = `http://127.0.0.1:${safetyPort}`; + export default defineConfig({ testDir: './tests', timeout: 45_000, @@ -9,7 +12,7 @@ export default defineConfig({ fullyParallel: false, reporter: process.env.CI ? [['list'], ['html', { open: 'never' }]] : 'list', use: { - baseURL: 'http://127.0.0.1:5173', + baseURL: safetyBaseUrl, trace: 'retain-on-failure', screenshot: 'only-on-failure', }, @@ -24,9 +27,9 @@ export default defineConfig({ }, ], webServer: { - command: 'pnpm --filter @summon-internal/demo dev --host 127.0.0.1 --port 5173', - url: 'http://127.0.0.1:5173/generate.html', - reuseExistingServer: !process.env.CI, + command: `pnpm --filter @summon-internal/demo dev --host 127.0.0.1 --port ${safetyPort} --strictPort`, + url: `${safetyBaseUrl}/generate.html`, + reuseExistingServer: false, timeout: 120_000, }, }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b5b2eda..4cfd3c3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -10,7 +10,7 @@ importers: devDependencies: '@anthropic-ai/sdk': specifier: ^0.88.0 - version: 0.88.0(zod@4.4.3) + version: 0.88.0 '@changesets/changelog-github': specifier: ^0.6.0 version: 0.6.0 @@ -56,7 +56,7 @@ importers: version: link:../../packages/summon-server '@anthropic-ai/sdk': specifier: ^0.88.0 - version: 0.88.0(zod@4.4.3) + version: 0.88.0 '@summon-internal/engine': specifier: workspace:* version: link:../../packages/engine @@ -83,6 +83,31 @@ importers: specifier: ^5.4.0 version: 5.9.3 + examples/surface-gallery: + dependencies: + '@anarchitecture/summon': + specifier: workspace:* + version: link:../../packages/summon + zod: + specifier: ^3.23.0 + version: 3.25.76 + devDependencies: + '@playwright/test': + specifier: ^1.60.0 + version: 1.60.0 + '@types/node': + specifier: ^20.11.0 + version: 20.19.39 + tsx: + specifier: ^4.7.0 + version: 4.21.0 + typescript: + specifier: ^5.4.0 + version: 5.9.3 + vite: + specifier: ^5.4.0 + version: 5.4.21(@types/node@20.19.39) + packages/devtools: devDependencies: typescript: @@ -639,66 +664,79 @@ packages: resolution: {integrity: sha512-2QxQrM+KQ7DAW4o22j+XZ6RKdxjLD7BOWTP0Bv0tmjdyhXSsr2Ul1oJDQqh9Zf5qOwTuTc7Ek83mOFaKnodPjg==} cpu: [arm] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm-musleabihf@4.60.2': resolution: {integrity: sha512-TbziEu2DVsTEOPif2mKWkMeDMLoYjx95oESa9fkQQK7r/Orta0gnkcDpzwufEcAO2BLBsD7mZkXGFqEdMRRwfw==} cpu: [arm] os: [linux] + libc: [musl] '@rollup/rollup-linux-arm64-gnu@4.60.2': resolution: {integrity: sha512-bO/rVDiDUuM2YfuCUwZ1t1cP+/yqjqz+Xf2VtkdppefuOFS2OSeAfgafaHNkFn0t02hEyXngZkxtGqXcXwO8Rg==} cpu: [arm64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm64-musl@4.60.2': resolution: {integrity: sha512-hr26p7e93Rl0Za+JwW7EAnwAvKkehh12BU1Llm9Ykiibg4uIr2rbpxG9WCf56GuvidlTG9KiiQT/TXT1yAWxTA==} cpu: [arm64] os: [linux] + libc: [musl] '@rollup/rollup-linux-loong64-gnu@4.60.2': resolution: {integrity: sha512-pOjB/uSIyDt+ow3k/RcLvUAOGpysT2phDn7TTUB3n75SlIgZzM6NKAqlErPhoFU+npgY3/n+2HYIQVbF70P9/A==} cpu: [loong64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-loong64-musl@4.60.2': resolution: {integrity: sha512-2/w+q8jszv9Ww1c+6uJT3OwqhdmGP2/4T17cu8WuwyUuuaCDDJ2ojdyYwZzCxx0GcsZBhzi3HmH+J5pZNXnd+Q==} cpu: [loong64] os: [linux] + libc: [musl] '@rollup/rollup-linux-ppc64-gnu@4.60.2': resolution: {integrity: sha512-11+aL5vKheYgczxtPVVRhdptAM2H7fcDR5Gw4/bTcteuZBlH4oP9f5s9zYO9aGZvoGeBpqXI/9TZZihZ609wKw==} cpu: [ppc64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-ppc64-musl@4.60.2': resolution: {integrity: sha512-i16fokAGK46IVZuV8LIIwMdtqhin9hfYkCh8pf8iC3QU3LpwL+1FSFGej+O7l3E/AoknL6Dclh2oTdnRMpTzFQ==} cpu: [ppc64] os: [linux] + libc: [musl] '@rollup/rollup-linux-riscv64-gnu@4.60.2': resolution: {integrity: sha512-49FkKS6RGQoriDSK/6E2GkAsAuU5kETFCh7pG4yD/ylj9rKhTmO3elsnmBvRD4PgJPds5W2PkhC82aVwmUcJ7A==} cpu: [riscv64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-riscv64-musl@4.60.2': resolution: {integrity: sha512-mjYNkHPfGpUR00DuM1ZZIgs64Hpf4bWcz9Z41+4Q+pgDx73UwWdAYyf6EG/lRFldmdHHzgrYyge5akFUW0D3mQ==} cpu: [riscv64] os: [linux] + libc: [musl] '@rollup/rollup-linux-s390x-gnu@4.60.2': resolution: {integrity: sha512-ALyvJz965BQk8E9Al/JDKKDLH2kfKFLTGMlgkAbbYtZuJt9LU8DW3ZoDMCtQpXAltZxwBHevXz5u+gf0yA0YoA==} cpu: [s390x] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-gnu@4.60.2': resolution: {integrity: sha512-UQjrkIdWrKI626Du8lCQ6MJp/6V1LAo2bOK9OTu4mSn8GGXIkPXk/Vsp4bLHCd9Z9Iz2OTEaokUE90VweJgIYQ==} cpu: [x64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-musl@4.60.2': resolution: {integrity: sha512-bTsRGj6VlSdn/XD4CGyzMnzaBs9bsRxy79eTqTCBsA8TMIEky7qg48aPkvJvFe1HyzQ5oMZdg7AnVlWQSKLTnw==} cpu: [x64] os: [linux] + libc: [musl] '@rollup/rollup-openbsd-x64@4.60.2': resolution: {integrity: sha512-6d4Z3534xitaA1FcMWP7mQPq5zGwBmGbhphh2DwaA1aNIXUu3KTOfwrWpbwI4/Gr0uANo7NTtaykFyO2hPuFLg==} @@ -1521,11 +1559,9 @@ snapshots: yaml: 2.8.3 zod: 4.4.3 - '@anthropic-ai/sdk@0.88.0(zod@4.4.3)': + '@anthropic-ai/sdk@0.88.0': dependencies: json-schema-to-ts: 3.1.1 - optionalDependencies: - zod: 4.4.3 '@babel/runtime@7.29.2': {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 4e708bd..6ec400e 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,3 +1,4 @@ packages: - 'packages/*' - 'apps/*' + - 'examples/*'