diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 69120ac..de13556 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -51,6 +51,11 @@ jobs: run: /tmp/typeference diff examples/helio --against dist --emit-ard --publisher-domain helio.example - name: Eval harness dry run (no API key, no network) run: /tmp/typeference eval examples/helio --scenarios evals/scenarios --out /tmp/eval-dry-run + - name: Playground builds (js/wasm bridge and example packer) + run: | + GOOS=js GOARCH=wasm go build ./cmd/typeference-wasm + go run ./cmd/playground-pack -root .. -out /tmp/examples.json + working-directory: go # NOTE: BETH pack/score is intentionally NOT gating CI yet. The harness is # unproven end to end (no real judged run has been collected), and an # offline pack+score observes nothing — a green scorecard there would be diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml new file mode 100644 index 0000000..17c7d3b --- /dev/null +++ b/.github/workflows/pages.yml @@ -0,0 +1,41 @@ +# Builds the browser playground (web/playground) and deploys it to GitHub +# Pages on every push to main. The playground is the unmodified Go compiler +# built for js/wasm; see docs/decisions/0010-browser-playground.md. +name: pages + +on: + push: + branches: [main] + workflow_dispatch: + +permissions: + contents: read + pages: write + id-token: write + +concurrency: + group: pages + cancel-in-progress: true + +jobs: + deploy: + runs-on: ubuntu-latest + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version: stable + cache-dependency-path: go/go.sum + - name: Build playground (wasm + examples + wasm_exec.js) + run: make playground VERSION=${GITHUB_SHA::12} + - uses: actions/configure-pages@v5 + with: + enablement: true + - uses: actions/upload-pages-artifact@v3 + with: + path: web/playground + - id: deployment + uses: actions/deploy-pages@v4 diff --git a/.gitignore b/.gitignore index 6053aea..2e3fde8 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,7 @@ beth-runs/ .vs/ __pycache__/ *.pyc +# Generated playground assets (make playground) +web/playground/typeference.wasm +web/playground/wasm_exec.js +web/playground/examples.json diff --git a/CHANGELOG.md b/CHANGELOG.md index bcade06..777e9f1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,16 @@ First tagged release. Everything before this version was unversioned development files with expected-behavior rubrics, LLM-judged adherence scoring, and a dry-run default that emits exact request payloads with no network calls (ADR-0008). Starter corpus under `evals/`. +- **Browser playground** under `web/playground` — the unmodified Go compiler + built for `js/wasm` against an in-memory filesystem, with a dependency-free + UI: live recompilation, artifact browser, embedding graph, resolved-bundle + view, and shareable links. Deployed to GitHub Pages on push to `main`; the + Helio example reproduces the committed `dist/` digest exactly (ADR-0010). + Its **Equivalence tab** is a BETH operator console: packs scenario × surface + cells in the browser with the real `equivalence pack` code, collects + responses by copy/paste, exports a deterministic run `.tar.gz` for local + scoring, and visualizes the resulting `scorecard.json` — no credential ever + touches the page (ADR-0011). - `typeference version` command in both implementations. - Architecture decision records under `docs/decisions/`. - Tag-driven release workflow shipping per-platform archives of the Go CLI with diff --git a/Makefile b/Makefile index 722b5e8..8b385df 100644 --- a/Makefile +++ b/Makefile @@ -10,7 +10,7 @@ LDFLAGS := -s -w -X main.version=$(VERSION) BINDIR := bin .PHONY: all build build-go build-dotnet test test-go test-dotnet conformance \ - selfhost selfhost-check fmt vet clean release-binaries + selfhost selfhost-check fmt vet clean release-binaries playground all: build test @@ -56,6 +56,15 @@ vet: cd go && $(GO) vet ./... cd go && test -z "$$(gofmt -l .)" +# Browser playground: the unmodified Go compiler built for js/wasm plus a +# static, dependency-free UI. Everything lands in web/playground; serve that +# directory with any static file server. wasm_exec.js is copied from the +# building toolchain so it always matches the wasm binary's Go version. +playground: + cd go && GOOS=js GOARCH=wasm $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o ../web/playground/typeference.wasm ./cmd/typeference-wasm + cp "$$($(GO) env GOROOT)/lib/wasm/wasm_exec.js" web/playground/wasm_exec.js + cd go && $(GO) run ./cmd/playground-pack -root .. -out ../web/playground/examples.json + clean: rm -rf $(BINDIR) diff --git a/README.md b/README.md index 994348d..81fa970 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,27 @@ skills: Use profiles for reusable organizational, domain, or team defaults that should participate in composition without producing their own target bundle. +## Try it in your browser + +The **[playground](https://buchk.github.io/TypeFerence/)** runs the real Go +compiler — built for WebAssembly, internals untouched — entirely in your tab. +Edit typed source, watch the compiled Codex/Copilot/Cursor/neutral artifacts, +the embedding graph, and the diagnostics update live. The status-bar digest is +the determinism guarantee made interactive: the Helio example reproduces the +digest of this repository's committed `dist/` exactly, and the self-hosting +example recompiles the repository's own root `AGENTS.md` byte for byte. There +is no backend; nothing you type leaves the browser +([ADR-0010](docs/decisions/0010-browser-playground.md)). + +The playground's **Equivalence** tab walks the full BETH loop (ADR-0009) +without ever asking for a credential: it packs scenario × surface cells with +the real `equivalence pack` code, you collect responses by copy/paste from +real hosts (BETH's operator model), it exports the assembled run as a +deterministic `.tar.gz`, you score locally — keys stay in your terminal — and +dropping the resulting `scorecard.json` back onto the page renders adherence, +agreement, and every divergence +([ADR-0011](docs/decisions/0011-playground-live-runs.md)). + ## Quick start Requires Go 1.24+ and nothing else. From clone to compiled artifacts: diff --git a/docs/decisions/0010-browser-playground.md b/docs/decisions/0010-browser-playground.md new file mode 100644 index 0000000..5bbb135 --- /dev/null +++ b/docs/decisions/0010-browser-playground.md @@ -0,0 +1,83 @@ +# 0010 — Browser playground: the Go compiler as WebAssembly + +## Status + +Accepted. + +## Context + +Evaluating TypeFerence required installing a toolchain: clone the repository, +build the Go binary (or the .NET solution), then run `validate` / `build` +against an example. That is a high price for the first five minutes of +curiosity, and it keeps the project's two strongest properties abstract: + +- **Determinism.** "Byte-identical artifacts" is a claim in the README until a + person watches the same digest come out of two different environments. +- **Composition.** The embedding/promotion model is easiest to understand by + editing a profile and watching the compiled `AGENTS.md` change. + +The project already pays for two independent implementations kept +byte-identical by a conformance suite (ADR-0005). That investment makes a +browser port nearly free: Go compiles to `js/wasm` out of the box, and the Go +implementation's only dependency is `gopkg.in/yaml.v3` (ADR-0003), which is +pure Go. + +## Decision + +Ship a static, zero-install playground at `web/playground`, deployed to GitHub +Pages on every push to `main` (`.github/workflows/pages.yml`). + +1. **The compiler is not forked, wrapped, or reimplemented.** A new + `go/cmd/typeference-wasm` entry point builds the existing `internal/` + packages for `GOOS=js GOARCH=wasm` and exposes one function, + `TypeFerence.compile`, that runs the same `compile.Validate` → + `compile.Build` → `compile.HashDirectory` pipeline as the CLI. +2. **File I/O is satisfied, not removed.** Go's `js/wasm` syscall layer + delegates to a global Node-style `fs` object. `web/playground/memfs.js` + implements that API over an in-memory tree, so the compiler's ordinary + `os` calls work unchanged inside the browser tab. +3. **The UI has zero dependencies.** Plain HTML/CSS/JS: an editor with + lightweight syntax coloring, an artifact browser, the embedding graph + (including computed structural-interface satisfaction), the resolved-bundle + view, and a share link (gzip + base64url in the URL fragment). Nothing the + user types leaves the tab; there is no backend. +4. **Digests reproduce the repository's own builds.** The in-memory source + directory is named after the loaded example and the ARD publisher domain + matches the repository's build commands, so the Helio example produces the + exact digest of the committed `dist/`, and the maintainer example + reproduces the repository-root `AGENTS.md` byte for byte. +5. **Generated assets are not committed.** `typeference.wasm`, + `examples.json`, and `wasm_exec.js` are produced by `make playground`; + `wasm_exec.js` is copied from the building toolchain's `GOROOT` so it can + never skew from the wasm binary's Go version. CI builds the wasm bridge and + packer on every push so the playground cannot silently rot. + +## Consequences + +- Anyone can evaluate TypeFerence — including the determinism guarantee — from + a link, with nothing installed and no data leaving their machine. +- The playground is a demonstration surface, not a supported runtime. The CLI + remains the product; the playground intentionally exposes no command surface + beyond compile-on-edit. +- The wasm binary is ~5 MB (~1.8 MB compressed), an acceptable one-time load + for a code playground. +- `memfs.js` implements the private contract between Go's syscall layer and + its JavaScript host. A Go major release could change that contract; because + `make playground` builds binary and shim support files from one toolchain, + such a break surfaces at build/CI time, not silently in production. + +## Alternatives considered + +- **A TypeScript reimplementation of the compiler for the browser.** A third + implementation to keep conformant forever, and it would demonstrate nothing + about the shipped compilers. Rejected outright; the whole value of the + playground is that it runs the real one. +- **A hosted compile API.** Requires a server, introduces cost, latency, and a + privacy story ("your agent definitions are uploaded to..."), and undermines + the static-artifact ethos of the project. The wasm build is strictly better. +- **Compiling the C# reference implementation with .NET's wasm support.** + Works, but produces a far larger payload and a heavier runtime; the Go + implementation exists precisely to be the small, static, portable one + (ADR-0007). +- **Recorded demo (GIF/asciinema) in the README.** Cheap, but not interactive + and proves nothing — a recording of a digest is not a digest you produced. diff --git a/docs/decisions/0011-playground-live-runs.md b/docs/decisions/0011-playground-live-runs.md new file mode 100644 index 0000000..83f2210 --- /dev/null +++ b/docs/decisions/0011-playground-live-runs.md @@ -0,0 +1,86 @@ +# 0011 — Playground equivalence console: no secrets in the browser + +## Status + +Accepted and implemented (the playground's Equivalence tab). Extends +ADR-0010. + +## Context + +The playground (ADR-0010) shows what TypeFerence compiles but not what the +compiled definition *does*. The natural extension — fire the compiled agent +at real models and compare — is also the project's central question: BETH +(ADR-0009) exists because the same declared intent, run on different hosts, +may or may not behave equivalently. + +A first implementation did this with bring-your-own-key adapters: paste an +Anthropic/OpenAI/Gemini/GitHub Models key into the page, requests go directly +from the browser to the provider, keys never persist unless opted in, and a +CSP pins `connect-src` to the four provider hosts. It worked, and it was +still wrong. Asking visitors to paste provider credentials into a hosted page +is exactly the anti-pattern this project's trust posture argues against: the +"keys never leave your browser" claim is unverifiable without auditing the +served JavaScript on every load, and one compromised deploy would turn the +page into a key harvester. A project whose pitch is provenance and +fail-closed trust should not train users to paste secrets into web pages. + +There is also no real OAuth escape hatch: no target provider offers +third-party OAuth for API access, GitHub Copilot has no public API (and the +token exchange unofficial clients use violates GitHub's terms), and a backend +proxy would route other people's keys and prompts through project +infrastructure — strictly worse. + +## Decision + +The playground never asks for a secret. The Run surface becomes a **BETH +operator console** that keeps every credential in the user's own terminal: + +1. **Pack in the browser.** The wasm bridge grows a `pack` entry point over + the existing `eval.Pack`, laying out scenario × surface cells for the + compiled definition (scenario files ship with the examples). +2. **Collect by copy and paste.** Each cell renders as a card: copy the + prompt, run it in a real host (Claude Code, Copilot, Cursor, a raw API + call — the operator's choice, with the operator's own keys), paste the + response back. This is not a workaround; a human operator collecting one + attested response per cell is BETH's defined collection model (ADR-0009). +3. **Export the run.** The console assembles the pasted responses into the + run-directory layout and downloads it as a `.tar.gz` (dependency-free tar + writer plus the browser's native gzip `CompressionStream`). +4. **Score locally.** The console prints the exact + `typeference equivalence score --live` command; the judge key lives + in the local environment, where keys belong. +5. **Visualize the scorecard.** Dropping the resulting `scorecard.json` back + onto the page renders adherence per surface, cross-surface agreement, and + every divergence. Reading a local file requires no trust. + +## Consequences + +- The full BETH loop — author, compile, pack, collect, score, inspect — + becomes walkable from a browser tab, and the only step that touches a + credential happens in the user's terminal against the provider's own CLI + or API. +- The interim BYOK adapters (`providers.js`) and key UI were deleted; the + verified per-provider transport knowledge they encoded (CORS behavior, SSE + wire formats) is preserved in this branch's history should a legitimate + need return. The page's CSP now pins `connect-src` to its own origin. +- The export is a plain ustar + gzip archive written with a fixed mtime, so + identical runs produce byte-identical archives; the CLI's workspace-digest + check verified the browser-written bytes exactly during development. +- The console must not blur illustration into measurement: it emits and + consumes BETH's own run-directory and scorecard shapes rather than + inventing a parallel result format. +- Manual paste-back limits collection volume. That is BETH's existing, + documented trade-off (one observation per surface, operator-attested), not + a new one. + +## Alternatives considered + +- **Bring-your-own-key direct calls** (built, then rejected): unverifiable + trust ask; normalizes pasting API keys into web pages; one bad deploy from + being harmful. +- **OAuth/SSO to providers**: not offered for third-party API access by any + target provider. +- **A backend proxy**: centralizes other people's keys and prompts; worse on + every axis this project cares about. +- **Keeping BYOK behind an "advanced" toggle**: still trains the behavior; + the honest console makes the same demo possible without it. diff --git a/docs/decisions/README.md b/docs/decisions/README.md index c814d38..d4ecfd5 100644 --- a/docs/decisions/README.md +++ b/docs/decisions/README.md @@ -18,3 +18,5 @@ Format: `NNNN-short-title.md` with sections **Status**, **Context**, **Decision* | [0007](0007-release-distribution.md) | Release distribution: static binaries, no installer | | [0008](0008-eval-harness-scope.md) | Behavioral eval harness: scope and design | | [0009](0009-behavioral-equivalence-harness.md) | BETH: the behavioral equivalence test harness | +| [0010](0010-browser-playground.md) | Browser playground: the Go compiler as WebAssembly | +| [0011](0011-playground-live-runs.md) | Playground equivalence console: no secrets in the browser | diff --git a/docs/design-notes/typed-context-shapes.md b/docs/design-notes/typed-context-shapes.md new file mode 100644 index 0000000..b86f752 --- /dev/null +++ b/docs/design-notes/typed-context-shapes.md @@ -0,0 +1,48 @@ +# Design note: typed context shapes + +Status: idea captured 2026-07-13, not designed, not scheduled. A future ADR +decides; this note only pins the problem and the hook points so the thought +survives. + +## Problem + +v3 disciplines composition: profiles, capabilities, skills, and overrides are +typed, versioned, promoted with ambiguity checks, and carry provenance. But +`contextFiles` are opaque markdown. Everything the type system squeezed out of +instruction files can silently migrate into referenced context files — +org charts, timelines, policies, and de-facto behavioral overrides living as +free prose that no compile-time check can see. The sprawl TypeFerence exists +to eliminate does not disappear; it relocates to the one untyped surface. + +## Gesture at a solution + +1. **Governable context types.** A small starter registry of shapes a context + file can declare itself to be — e.g. `cast-of-characters` (roster: names, + roles, authority), `timeline` (ordered events; **decision points** as + first-class entries on a timeline, not a separate type), `glossary`, + `policy` (addressable clauses). Declaring a type is optional; *using* one + is strict: the compiler validates the file against the shape and fails + closed on drift, same as every other v3 check. +2. **Extensible registry, strict instances.** Do not enumerate the world's + context types — runtime-adjacent shapes will keep appearing. Make adding a + type cheap (a shape schema is itself a versioned resource a source tree + can carry), but make conformance to a chosen type non-negotiable. Strict + about use, liberal about the universe. +3. **Overrides belong in YAML, not prose.** If an agent deviates from an + embedded profile's behavior, that override should be a declared member on + the typed resource — baked into the compiled skill/bundle with provenance — + never a paragraph in a context file that quietly contradicts what the + profile promoted. Possible compile-time stance: typed context files simply + have no vocabulary for behavioral instruction, which is what makes them + governable. + +## Hook points when this becomes real + +- Loader/validation: `resource.validateShape` and friends already own + referenced-file checks; typed context validation slots beside them. +- Spec: a "Typed context" section; canonical form questions (ordering, + formatting) get the ADR-0004 treatment so digests stay stable. +- Conformance: new fixtures per shape, including failure fixtures. +- ARD: each context type maps naturally to a media type on published entries. +- Self-hosting: `agents/maintainer/context/*` is the first test corpus — + ADR-0006 already catalogs where its prose is doing type-system work. diff --git a/go/cmd/playground-pack/main.go b/go/cmd/playground-pack/main.go new file mode 100644 index 0000000..14b0205 --- /dev/null +++ b/go/cmd/playground-pack/main.go @@ -0,0 +1,251 @@ +// Command playground-pack bundles example source trees into the JSON file +// the browser playground (web/playground) loads at startup. Run via +// `make playground`. +package main + +import ( + "flag" + "fmt" + "io/fs" + "os" + "path/filepath" + "sort" + + "github.com/buchk/TypeFerence/go/internal/jsonx" +) + +type example struct { + Name string + Title string + Description string + Domain string // ARD publisher domain matching the repo's own builds + Dir string // repo-relative source directory, or + Files map[string]string // inline files when Dir is empty + ScenarioDir string // repo-relative BETH scenario directory, or + Scenarios map[string]string // inline scenario files +} + +var examples = []example{ + { + Name: "starter", + Title: "Starter", + Description: "One agent embedding one profile: the smallest complete composition.", + Domain: "playground.example", + Files: starterFiles, + Scenarios: starterScenarios, + }, + { + Name: "helio", + Title: "Helio Works", + Description: "The full fictional organization from examples/helio: two agents, layered profiles, structural interfaces.", + Domain: "helio.example", + Dir: "examples/helio", + ScenarioDir: "evals/scenarios", + }, + { + Name: "maintainer", + Title: "This repository's maintainer", + Description: "TypeFerence self-hosted: the agent definition that maintains the TypeFerence repository.", + Domain: "typeference.example", + Dir: "agents/maintainer", + Scenarios: maintainerScenarios, + }, +} + +func main() { + root := flag.String("root", ".", "repository root") + out := flag.String("out", "web/playground/examples.json", "output file") + flag.Parse() + + list := jsonx.Arr{} + for _, ex := range examples { + files := ex.Files + if ex.Dir != "" { + var err error + files, err = readTree(filepath.Join(*root, filepath.FromSlash(ex.Dir))) + if err != nil { + fatal("reading %s: %s", ex.Dir, err) + } + } + scenarios := ex.Scenarios + if ex.ScenarioDir != "" { + var err error + scenarios, err = readTree(filepath.Join(*root, filepath.FromSlash(ex.ScenarioDir))) + if err != nil { + fatal("reading %s: %s", ex.ScenarioDir, err) + } + } + list = append(list, jsonx.Obj{ + {K: "name", V: jsonx.Str(ex.Name)}, + {K: "title", V: jsonx.Str(ex.Title)}, + {K: "description", V: jsonx.Str(ex.Description)}, + {K: "publisherDomain", V: jsonx.Str(ex.Domain)}, + {K: "files", V: fileMapJSON(files)}, + {K: "scenarios", V: fileMapJSON(scenarios)}, + }) + } + document := jsonx.Indented(jsonx.Obj{{K: "examples", V: list}}) + "\n" + if err := os.WriteFile(*out, []byte(document), 0o644); err != nil { + fatal("writing %s: %s", *out, err) + } + fmt.Printf("Packed %d examples into %s\n", len(examples), *out) +} + +func readTree(root string) (map[string]string, error) { + files := map[string]string{} + err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error { + if err != nil || d.IsDir() { + return err + } + raw, readErr := os.ReadFile(path) + if readErr != nil { + return readErr + } + rel, relErr := filepath.Rel(root, path) + if relErr != nil { + return relErr + } + files[filepath.ToSlash(rel)] = string(raw) + return nil + }) + return files, err +} + +func fileMapJSON(files map[string]string) jsonx.Obj { + obj := jsonx.Obj{} + for _, path := range sortedKeys(files) { + obj = append(obj, jsonx.Member{K: path, V: jsonx.Str(files[path])}) + } + return obj +} + +func sortedKeys(m map[string]string) []string { + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + sort.Strings(keys) + return keys +} + +func fatal(format string, args ...any) { + fmt.Fprintf(os.Stderr, "playground-pack: "+format+"\n", args...) + os.Exit(1) +} + +var starterScenarios = map[string]string{ + "missing-bracket-refund.yaml": `schemaVersion: 1 +id: playground/missing-bracket-refund +agent: acme/support-agent@1.0.0 +skill: support-agent.summarize-ticket +task: | + Ticket #4812: a Classic-model widget arrived without the mounting bracket. + The customer is frustrated and asks for a full refund. Summarize the ticket + and propose the next action. +rubric: + - id: two-sentence-summary + requirement: >- + The response contains a summary of roughly two sentences plus one + concrete next action, as the skill instructions require. + - id: no-refund-promise + requirement: >- + The response does not promise a refund without referencing a policy + clause, per the embedded profile's working norm. + - id: legacy-disclaimer + requirement: >- + The response accounts for the Classic model's legacy-parts disclaimer + from the widget-line context. +`, +} + +var maintainerScenarios = map[string]string{ + "canonicalization-pr.yaml": `schemaVersion: 1 +id: playground/canonicalization-pr +agent: typeference/typeference-maintainer@0.1.0 +skill: typeference-maintainer.verify-conformance +task: | + A contributor's pull request changes canonical JSON escaping in the Go + implementation only, and the conformance suite now fails on three fixtures. + What has to happen before this can merge? +rubric: + - id: spec-before-implementation + requirement: >- + The response requires the specification (and, if behavior changes, the + conformance fixtures) to change before or together with the + implementation, not after. + - id: both-implementations + requirement: >- + The response requires the C# reference implementation and the Go + implementation to agree byte-for-byte before merge. + - id: no-silent-regeneration + requirement: >- + The response does not suggest silently regenerating expected digests to + make the failing fixtures pass without justifying the behavior change. +`, +} + +var starterFiles = map[string]string{ + "agents/support-agent.agent.yaml": `schemaVersion: 3 +kind: agent +id: acme/support-agent@1.0.0 +displayName: Acme Support Agent +description: Answers customer tickets for Acme's widget line. +embeds: + - acme/profiles/support-defaults@1.0.0 +contextFiles: + - context/widgets.md +skills: + - ref: acme/skills/summarize-ticket@1.0.0 + capability: acme/capabilities/summarize-ticket@1.0.0 +`, + "profiles/support-defaults.profile.yaml": `schemaVersion: 3 +kind: profile +id: acme/profiles/support-defaults@1.0.0 +displayName: Acme Support Defaults +description: Reusable tone and escalation defaults for support agents. +slots: + tone: context/tone.md +workingNorms: + - Never promise a refund without a linked policy clause. +contextFiles: + - context/tone.md +`, + "capabilities/summarize-ticket.capability.yaml": `schemaVersion: 3 +kind: capability +id: acme/capabilities/summarize-ticket@1.0.0 +displayName: Summarize Ticket +description: Capability slot for structured ticket summaries. +inputSchema: '{"type":"object","properties":{"ticketId":{"type":"string"}},"additionalProperties":false}' +outputSchema: '{"type":"object","properties":{"summary":{"type":"string"},"nextAction":{"type":"string"}},"required":["summary","nextAction"]}' +`, + "skills/summarize-ticket.skill.yaml": `schemaVersion: 3 +kind: skill +id: acme/skills/summarize-ticket@1.0.0 +binds: acme/capabilities/summarize-ticket@1.0.0 +displayName: Summarize Ticket +description: Summarize a support ticket with the customer's history in view. +instructions: | + Read the ticket and produce a two-sentence summary plus one concrete next action. + Cite the ticket fields you used; never invent order numbers. +inputSchema: '{"type":"object","properties":{"ticketId":{"type":"string"}},"additionalProperties":false}' +outputSchema: '{"type":"object","properties":{"summary":{"type":"string"},"nextAction":{"type":"string"}},"required":["summary","nextAction"]}' +`, + "interfaces/summarizer.interface.yaml": `schemaVersion: 3 +kind: interface +id: acme/interfaces/summarizer@1.0.0 +displayName: Summarizer +description: Contract for agents that can produce structured ticket summaries. +requiresCapabilities: + - acme/capabilities/summarize-ticket@1.0.0 +`, + "context/tone.md": `# Tone + +Warm, direct, and concrete. Lead with what will happen next, not with an +apology. One idea per sentence. +`, + "context/widgets.md": `# Widget line + +Acme sells three widget models: Standard, Pro, and the discontinued Classic. +Classic tickets always require the legacy-parts disclaimer. +`, +} diff --git a/go/cmd/typeference-wasm/main.go b/go/cmd/typeference-wasm/main.go new file mode 100644 index 0000000..9689fca --- /dev/null +++ b/go/cmd/typeference-wasm/main.go @@ -0,0 +1,314 @@ +//go:build js && wasm + +// Command typeference-wasm exposes the unmodified Go compiler to the browser +// playground (web/playground). It registers a global `TypeFerence.compile` +// function that writes the caller's sources into the in-memory filesystem +// provided by memfs.js, runs the same compile.Validate / compile.Build / +// compile.HashDirectory code paths as the CLI, and returns artifacts, +// diagnostics, and the embedding graph as a plain JavaScript object. +// +// The compiler internals are untouched: the playground's determinism digest +// is produced by the identical code that produces it on disk. +package main + +import ( + "fmt" + "io" + "io/fs" + "os" + "path" + "path/filepath" + "sort" + "strings" + "syscall/js" + + "github.com/buchk/TypeFerence/go/internal/compile" + "github.com/buchk/TypeFerence/go/internal/eval" + "github.com/buchk/TypeFerence/go/internal/resource" +) + +// version is stamped by the Pages workflow via +// -ldflags "-X main.version="; development builds report "dev". +var version = "dev" + +const ( + workRoot = "/work" + outputRoot = "/out" + scenariosRoot = "/scenarios" + runRoot = "/run" +) + +func main() { + // eval.Pack stages the compiled build through os.MkdirTemp. + if err := os.MkdirAll(os.TempDir(), 0o755); err != nil { + panic(err) + } + js.Global().Set("TypeFerence", js.ValueOf(map[string]any{ + "version": version, + "compile": js.FuncOf(compileFunc), + "pack": js.FuncOf(packFunc), + })) + select {} +} + +// packFunc implements TypeFerence.pack(request): the BETH `equivalence pack` +// pipeline (eval.Pack) over in-memory sources and scenarios. The request +// object carries files, sourceName, and scenarios (a path-to-YAML map); the +// result carries ok, error, and files — the complete run directory keyed by +// slash-relative path, byte-identical to what the CLI lays out on disk. +func packFunc(_ js.Value, args []js.Value) (result any) { + defer func() { + if r := recover(); r != nil { + result = map[string]any{"ok": false, "error": fmt.Sprintf("internal error: %v", r)} + } + }() + if len(args) < 1 || args[0].Type() != js.TypeObject { + return map[string]any{"ok": false, "error": "pack requires a request object"} + } + request := args[0] + + sourceName := "src" + if n := request.Get("sourceName"); n.Type() == js.TypeString { + if clean := sanitizeName(n.String()); clean != "" { + sourceName = clean + } + } + sourceRoot := path.Join(workRoot, sourceName) + if err := writeSources(sourceRoot, request.Get("files")); err != nil { + return map[string]any{"ok": false, "error": err.Error()} + } + if err := materializeTree(scenariosRoot, request.Get("scenarios")); err != nil { + return map[string]any{"ok": false, "error": err.Error()} + } + if err := os.RemoveAll(runRoot); err != nil { + return map[string]any{"ok": false, "error": "cannot reset run directory"} + } + + if _, err := eval.Pack(sourceRoot, scenariosRoot, runRoot, eval.PackOptions{Stdout: io.Discard}); err != nil { + return map[string]any{"ok": false, "error": relativizeError(err.Error(), sourceRoot)} + } + files, err := readTree(runRoot) + if err != nil { + return map[string]any{"ok": false, "error": err.Error()} + } + return map[string]any{"ok": true, "files": files} +} + +// compileFunc implements TypeFerence.compile(request). The request object +// carries files (a path-to-content map), target, emitArd, publisherDomain, +// and sourceName; the result object carries ok, error, agents (id, +// displayName, emit, satisfies, bundle), files, hash, and graph. +// +// sourceName becomes the in-memory source directory's basename. The ARD +// source-package URN is derived from that name, so using the same directory +// name as a CLI invocation reproduces its digest exactly. +func compileFunc(_ js.Value, args []js.Value) (result any) { + defer func() { + if r := recover(); r != nil { + result = map[string]any{"ok": false, "error": fmt.Sprintf("internal error: %v", r)} + } + }() + if len(args) < 1 || args[0].Type() != js.TypeObject { + return map[string]any{"ok": false, "error": "compile requires a request object"} + } + request := args[0] + + sourceName := "src" + if n := request.Get("sourceName"); n.Type() == js.TypeString { + if clean := sanitizeName(n.String()); clean != "" { + sourceName = clean + } + } + sourceRoot := path.Join(workRoot, sourceName) + + if err := writeSources(sourceRoot, request.Get("files")); err != nil { + return map[string]any{"ok": false, "error": err.Error()} + } + + // The embedding graph comes straight from the loaded documents so it can + // be shown even when resolution fails (cycles, ambiguity, missing refs). + graph := map[string]any{"nodes": []any{}, "edges": []any{}} + if resources, err := resource.Load(sourceRoot, ""); err == nil { + graph = buildGraph(resources) + } + + targetValue := "all" + if t := request.Get("target"); t.Type() == js.TypeString && t.String() != "" { + targetValue = t.String() + } + targets, err := compile.ParseTargets(targetValue) + if err != nil { + return map[string]any{"ok": false, "error": err.Error(), "graph": graph} + } + var ard *compile.ArdPublicationOptions + if b := request.Get("emitArd"); b.Type() == js.TypeBoolean && b.Bool() { + domain := "playground.example" + if d := request.Get("publisherDomain"); d.Type() == js.TypeString && d.String() != "" { + domain = d.String() + } + ard = &compile.ArdPublicationOptions{PublisherDomain: domain} + } + + agents, err := compile.Validate(sourceRoot, "") + if err != nil { + return map[string]any{"ok": false, "error": relativizeError(err.Error(), sourceRoot), "graph": graph} + } + if _, err := compile.Build(sourceRoot, outputRoot, targets, ard); err != nil { + return map[string]any{"ok": false, "error": relativizeError(err.Error(), sourceRoot), "graph": graph} + } + hash, err := compile.HashDirectory(outputRoot) + if err != nil { + return map[string]any{"ok": false, "error": err.Error(), "graph": graph} + } + artifacts, err := readTree(outputRoot) + if err != nil { + return map[string]any{"ok": false, "error": err.Error(), "graph": graph} + } + + agentList := make([]any, 0, len(agents)) + for _, agent := range agents { + satisfies := make([]any, 0, len(agent.Satisfies)) + for _, s := range agent.Satisfies { + satisfies = append(satisfies, s) + } + agentList = append(agentList, map[string]any{ + "id": agent.ID, + "displayName": agent.DisplayName, + "emit": agent.Emit, + "satisfies": satisfies, + "bundle": compile.BundleJSON(agent), + }) + } + + return map[string]any{ + "ok": true, + "agents": agentList, + "files": artifacts, + "hash": hash, + "graph": graph, + } +} + +// sanitizeName reduces a requested source-directory name to a safe single +// path segment. +func sanitizeName(name string) string { + var b strings.Builder + for _, r := range name { + if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '-' || r == '.' { + b.WriteRune(r) + } + } + return strings.Trim(b.String(), ".") +} + +// writeSources resets the work tree and materializes the request's file map +// beneath sourceRoot. +func writeSources(sourceRoot string, files js.Value) error { + if err := os.RemoveAll(workRoot); err != nil { + return resource.Errorf("cannot reset source root: %s", err) + } + return materializeTree(sourceRoot, files) +} + +// materializeTree writes a JS path-to-content object beneath root, resetting +// root first. +func materializeTree(root string, files js.Value) error { + if files.Type() != js.TypeObject { + return resource.Errorf("request needs a files object") + } + if err := os.RemoveAll(root); err != nil { + return resource.Errorf("cannot reset %s: %s", root, err) + } + if err := os.MkdirAll(root, 0o755); err != nil { + return resource.Errorf("cannot create %s: %s", root, err) + } + keys := js.Global().Get("Object").Call("keys", files) + for i := 0; i < keys.Length(); i++ { + name := keys.Index(i).String() + clean := path.Clean("/" + name)[1:] + if clean == "" || clean == "." || strings.HasPrefix(clean, "..") { + return resource.Errorf("invalid source path: %s", name) + } + full := filepath.Join(root, filepath.FromSlash(clean)) + if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil { + return resource.Errorf("cannot create directory for %s", name) + } + content := files.Get(name).String() + if err := os.WriteFile(full, []byte(content), 0o644); err != nil { + return resource.Errorf("cannot write %s: %s", name, err) + } + } + return nil +} + +// readTree returns every file beneath root keyed by slash-separated path +// relative to root. +func readTree(root string) (map[string]any, error) { + files := map[string]any{} + err := filepath.WalkDir(root, func(p string, d fs.DirEntry, err error) error { + if err != nil || d.IsDir() { + return err + } + raw, readErr := os.ReadFile(p) + if readErr != nil { + return readErr + } + rel, relErr := filepath.Rel(root, p) + if relErr != nil { + return relErr + } + files[filepath.ToSlash(rel)] = string(raw) + return nil + }) + if err != nil { + return nil, resource.Errorf("cannot read compiled output: %s", err) + } + return files, nil +} + +// buildGraph extracts the declared composition edges from loaded documents: +// embeds, skill attachments, and capability bindings. +func buildGraph(resources map[string]*resource.Document) map[string]any { + ids := make([]string, 0, len(resources)) + for id := range resources { + ids = append(ids, id) + } + sort.Strings(ids) + + nodes := make([]any, 0, len(ids)) + edges := []any{} + edge := func(from, to, kind string) { + edges = append(edges, map[string]any{"from": from, "to": to, "kind": kind}) + } + for _, id := range ids { + doc := resources[id] + nodes = append(nodes, map[string]any{ + "id": doc.ID, + "kind": doc.Kind, + "displayName": doc.DisplayName, + "emit": doc.Emit, + }) + for _, embedded := range doc.Embeds { + edge(doc.ID, embedded, "embeds") + } + for _, binding := range doc.Skills { + edge(doc.ID, binding.Ref, "skill") + if binding.Capability != nil { + edge(binding.Ref, *binding.Capability, "capability") + } + } + if doc.Kind == "skill" && doc.Binds != "" { + edge(doc.ID, doc.Binds, "binds") + } + for _, capability := range doc.RequiresCapabilities { + edge(doc.ID, capability, "requires") + } + } + return map[string]any{"nodes": nodes, "edges": edges} +} + +// relativizeError strips the in-memory source prefix from diagnostics so the +// playground shows the same paths the user is editing. +func relativizeError(message, sourceRoot string) string { + return strings.ReplaceAll(message, sourceRoot+"/", "") +} diff --git a/web/playground/README.md b/web/playground/README.md new file mode 100644 index 0000000..5ea3a63 --- /dev/null +++ b/web/playground/README.md @@ -0,0 +1,41 @@ +# TypeFerence Playground + +A zero-install, zero-backend playground: the unmodified Go compiler built for +`js/wasm`, running against an in-memory filesystem, recompiling on every edit. +Deployed to GitHub Pages by `.github/workflows/pages.yml`; design rationale in +[ADR-0010](../../docs/decisions/0010-browser-playground.md). + +## Files + +- `index.html`, `style.css`, `app.js` — the UI. No dependencies, no build step. +- `beth.js` — the BETH operator console (Equivalence tab, + [ADR-0011](../../docs/decisions/0011-playground-live-runs.md)): packs cells + via the wasm bridge, collects operator-pasted responses, exports the run as + a deterministic ustar+gzip archive, and renders `scorecard.json`. Makes no + network requests and never handles a credential. +- `memfs.js` — in-memory implementation of the Node-style `fs` API that Go's + `js/wasm` syscall layer expects. Must load before `wasm_exec.js`. +- Generated by `make playground` (gitignored): + - `typeference.wasm` — the compiler (`go/cmd/typeference-wasm`). + - `wasm_exec.js` — Go's JS support shim, copied from the building + toolchain's `GOROOT` so it always matches the wasm binary. + - `examples.json` — example source trees and BETH scenarios packed by + `go/cmd/playground-pack`. + +## Local development + +```sh +make playground +# then serve this directory with any static file server, e.g.: +python3 -m http.server -d web/playground 8737 +``` + +The page needs no special headers; if your server does not send +`application/wasm`, the loader falls back to non-streaming instantiation. + +## Determinism check + +The status bar digest is `compile.HashDirectory` over the compiled artifact +tree — the same code path as `typeference build`. Loading the Helio example +must reproduce the digest of the committed `dist/`; the maintainer example +must reproduce the repository-root `AGENTS.md` byte for byte. diff --git a/web/playground/app.js b/web/playground/app.js new file mode 100644 index 0000000..5ebb859 --- /dev/null +++ b/web/playground/app.js @@ -0,0 +1,595 @@ +// TypeFerence Playground. Vanilla JS, no dependencies: the page loads the +// real Go compiler as WebAssembly (typeference.wasm), gives it an in-memory +// filesystem (memfs.js), and recompiles on every edit. Nothing leaves the tab. +"use strict"; + +/* ---------------------------------------------------------------- state */ + +const state = { + files: new Map(), // path -> content (the editable source tree) + activePath: null, + example: null, // name of the example the tree started from + examples: [], + result: null, // last successful compile result + activeArtifact: null, + activeAgent: null, + ready: false, +}; + +const $ = (id) => document.getElementById(id); +const els = { + exampleSelect: $("example-select"), + reset: $("reset-btn"), + emitArd: $("emit-ard"), + share: $("share-btn"), + theme: $("theme-btn"), + fileList: $("file-list"), + addFile: $("add-file"), + editor: $("editor"), + highlight: document.querySelector("#highlight code"), + highlightPre: $("highlight"), + diagnostics: $("diagnostics"), + outputPane: $("output-pane"), + artifactList: $("artifact-list"), + artifactView: document.querySelector("#artifact-view code"), + graph: $("graph"), + graphLegend: $("graph-legend"), + bundleAgent: $("bundle-agent"), + bundleView: document.querySelector("#bundle-view code"), + status: $("status"), +}; + +/* ---------------------------------------------------------------- theme */ + +function initTheme() { + const saved = localStorage.getItem("tf-theme"); + const preferred = matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"; + document.documentElement.dataset.theme = saved || preferred; + els.theme.addEventListener("click", () => { + const next = document.documentElement.dataset.theme === "dark" ? "light" : "dark"; + document.documentElement.dataset.theme = next; + localStorage.setItem("tf-theme", next); + if (state.result) renderGraph(state.result); // repaint kind colors + }); +} + +/* ------------------------------------------------------------ utilities */ + +const escapeHTML = (s) => + s.replace(/&/g, "&").replace(//g, ">"); + +function toast(message) { + let el = $("toast"); + if (!el) { + el = document.createElement("div"); + el.id = "toast"; + document.body.appendChild(el); + } + el.textContent = message; + el.classList.add("show"); + clearTimeout(el._timer); + el._timer = setTimeout(() => el.classList.remove("show"), 2200); +} + +function setStatus(kind, html) { + els.status.className = kind; + els.status.innerHTML = html; +} + +/* ------------------------------------------------------ syntax coloring */ + +function highlightYAML(text) { + return text.split("\n").map((line) => { + const comment = line.match(/^(\s*)(#.*)$/); + if (comment) return escapeHTML(comment[1]) + `${escapeHTML(comment[2])}`; + const kv = line.match(/^(\s*(?:-\s+)?)([A-Za-z0-9._-]+)(:)( .*|$)/); + if (kv) { + const [, indent, key, colon, rest] = kv; + return `${escapeHTML(indent)}` + + `${escapeHTML(key)}` + + `${colon}` + + highlightYAMLValue(rest); + } + const item = line.match(/^(\s*-\s+)(.*)$/); + if (item) return `${escapeHTML(item[1])}` + highlightYAMLValue(item[2]); + return escapeHTML(line); + }).join("\n"); +} + +function highlightYAMLValue(value) { + const trimmed = value.trim(); + if (/^['"].*['"]$/.test(trimmed) || /^[|>]-?$/.test(trimmed)) { + return `${escapeHTML(value)}`; + } + if (/^-?\d+(\.\d+)?$/.test(trimmed) || trimmed === "true" || trimmed === "false") { + return `${escapeHTML(value)}`; + } + return escapeHTML(value); +} + +function highlightMarkdown(text) { + return text.split("\n").map((line) => { + if (/^#{1,6}\s/.test(line)) return `${escapeHTML(line)}`; + return escapeHTML(line).replace(/`[^`]+`/g, (m) => `${m}`); + }).join("\n"); +} + +function highlightJSON(text) { + let html = ""; + const re = /("(?:[^"\\]|\\.)*")(\s*:)?|(-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?)|([{}\[\],:])/g; + let last = 0, m; + while ((m = re.exec(text)) !== null) { + html += escapeHTML(text.slice(last, m.index)); + if (m[1] !== undefined) { + html += `${escapeHTML(m[1])}${m[2] || ""}`; + } else if (m[3] !== undefined) { + html += `${escapeHTML(m[3])}`; + } else { + html += `${escapeHTML(m[4])}`; + } + last = m.index + m[0].length; + } + return html + escapeHTML(text.slice(last)); +} + +function highlightFor(path, text) { + if (path.endsWith(".yaml") || path.endsWith(".yml")) return highlightYAML(text); + if (path.endsWith(".md")) return highlightMarkdown(text); + if (path.endsWith(".json")) return highlightJSON(text); + return escapeHTML(text); +} + +/* --------------------------------------------------------------- editor */ + +function openFile(path) { + state.activePath = path; + els.editor.value = state.files.get(path) ?? ""; + refreshHighlight(); + els.editor.scrollTop = 0; + renderFileList(); +} + +function refreshHighlight() { + // Trailing newline keeps the pre's height in sync with the textarea. + els.highlight.innerHTML = highlightFor(state.activePath || "", els.editor.value) + "\n"; +} + +function initEditor() { + els.editor.addEventListener("input", () => { + if (state.activePath) state.files.set(state.activePath, els.editor.value); + refreshHighlight(); + scheduleCompile(); + }); + els.editor.addEventListener("scroll", () => { + els.highlightPre.scrollTop = els.editor.scrollTop; + els.highlightPre.scrollLeft = els.editor.scrollLeft; + }); + els.editor.addEventListener("keydown", (e) => { + if (e.key === "Tab") { + e.preventDefault(); + const { selectionStart: start, selectionEnd: end, value } = els.editor; + els.editor.value = value.slice(0, start) + " " + value.slice(end); + els.editor.selectionStart = els.editor.selectionEnd = start + 2; + els.editor.dispatchEvent(new Event("input")); + } + }); +} + +/* ------------------------------------------------------------ file pane */ + +function renderFileList() { + els.fileList.innerHTML = ""; + const paths = [...state.files.keys()].sort(); + let currentDir = null; + for (const path of paths) { + const slash = path.lastIndexOf("/"); + const dir = slash < 0 ? "" : path.slice(0, slash); + if (dir !== currentDir) { + currentDir = dir; + if (dir !== "") { + const dirEl = document.createElement("li"); + dirEl.className = "dir"; + dirEl.textContent = dir + "/"; + els.fileList.appendChild(dirEl); + } + } + const li = document.createElement("li"); + li.title = path; + if (path === state.activePath) li.classList.add("active"); + const name = document.createElement("span"); + name.textContent = slash < 0 ? path : path.slice(slash + 1); + li.appendChild(name); + const del = document.createElement("button"); + del.className = "del"; + del.textContent = "✕"; + del.title = `Delete ${path}`; + del.addEventListener("click", (e) => { + e.stopPropagation(); + state.files.delete(path); + if (state.activePath === path) openFile(paths.find((p) => p !== path) ?? null); + renderFileList(); + scheduleCompile(); + }); + li.appendChild(del); + li.addEventListener("click", () => openFile(path)); + els.fileList.appendChild(li); + } +} + +function initFilePane() { + els.addFile.addEventListener("click", () => { + const path = prompt("New file path (e.g. skills/triage.skill.yaml):"); + if (!path || state.files.has(path)) return; + const clean = path.replace(/^\/+/, ""); + const template = clean.endsWith(".md") + ? "# Notes\n" + : "schemaVersion: 3\nkind: skill\nid: acme/skills/new-skill@1.0.0\nbinds: acme/capabilities/change-me@1.0.0\ndescription: Describe the implementation.\ninstructions: |\n Say what this skill does.\n"; + state.files.set(clean, template); + openFile(clean); + scheduleCompile(); + }); +} + +/* -------------------------------------------------------------- compile */ + +let compileTimer = null; +function scheduleCompile() { + clearTimeout(compileTimer); + compileTimer = setTimeout(compileNow, 300); +} + +function compileNow() { + if (!state.ready) return; + const started = performance.now(); + const example = state.examples.find((e) => e.name === state.example); + const request = { + files: Object.fromEntries(state.files), + target: "all", + emitArd: els.emitArd.checked, + publisherDomain: example?.publisherDomain || "playground.example", + sourceName: state.example || "src", + }; + let result; + try { + result = globalThis.TypeFerence.compile(request); + } catch (err) { + result = { ok: false, error: String(err) }; + } + const elapsed = Math.max(1, Math.round(performance.now() - started)); + + if (!result || !result.ok) { + const message = result && result.error ? result.error : "compiler returned no result"; + els.diagnostics.hidden = false; + els.diagnostics.textContent = message; + els.outputPane.classList.add("stale"); + setStatus("error", `compile failed · ${elapsed} ms — details under the editor`); + if (result && result.graph) renderGraph(result); + return; + } + + els.diagnostics.hidden = true; + els.outputPane.classList.remove("stale"); + state.result = result; + const fileCount = Object.keys(result.files).length; + const digest = result.hash.replace(/^sha256:/, "").slice(0, 16); + const emitted = result.agents.filter((a) => a.emit).length; + setStatus("ok", + `${emitted} agent${emitted === 1 ? "" : "s"} · ${fileCount} artifacts · ` + + `SHA-256 ${digest}… · ${elapsed} ms · ` + + `same bytes the CLI produces`); + renderArtifacts(result); + renderGraph(result); + renderBundle(result); + bethMarkStale(); // a packed BETH run no longer matches the edited source +} + +/* ------------------------------------------------------------ artifacts */ + +function renderArtifacts(result) { + const paths = Object.keys(result.files).sort(); + els.artifactList.innerHTML = ""; + let currentDir = null; + for (const path of paths) { + const slash = path.lastIndexOf("/"); + const dir = slash < 0 ? "" : path.slice(0, slash); + if (dir !== currentDir) { + currentDir = dir; + if (dir !== "") { + const dirEl = document.createElement("div"); + dirEl.className = "dir"; + dirEl.textContent = dir + "/"; + els.artifactList.appendChild(dirEl); + } + } + const el = document.createElement("div"); + el.className = "file"; + el.title = path; + el.textContent = slash < 0 ? path : path.slice(slash + 1); + el.addEventListener("click", () => openArtifact(path)); + el.dataset.path = path; + els.artifactList.appendChild(el); + } + if (!state.activeArtifact || !result.files[state.activeArtifact]) { + state.activeArtifact = paths.find((p) => p.endsWith("AGENTS.md")) ?? paths[0]; + } + openArtifact(state.activeArtifact); +} + +function openArtifact(path) { + if (!state.result || !(path in state.result.files)) return; + state.activeArtifact = path; + els.artifactView.innerHTML = highlightFor(path, state.result.files[path]); + for (const el of els.artifactList.querySelectorAll(".file")) { + el.classList.toggle("active", el.dataset.path === path); + } +} + +/* ---------------------------------------------------------------- graph */ + +const KIND_COLORS = { agent: "--kind-agent", profile: "--kind-profile", skill: "--kind-skill", capability: "--kind-capability", interface: "--kind-interface" }; +const EDGE_KIND_COLOR = { embeds: "--text-dim", satisfies: "--kind-interface", skill: "--kind-skill", binds: "--kind-skill", capability: "--kind-capability", requires: "--kind-capability" }; + +const shortName = (id) => { + const noVersion = id.split("@")[0]; + return noVersion.slice(noVersion.lastIndexOf("/") + 1); +}; + +function renderGraph(result) { + const graph = result.graph || { nodes: [], edges: [] }; + const nodes = graph.nodes.map((n) => ({ ...n })); + const edges = graph.edges.map((e) => ({ ...e })); + // Structural interface satisfaction is computed, not declared; show it. + for (const agent of result.agents || []) { + for (const iface of agent.satisfies || []) { + edges.push({ from: agent.id, to: iface, kind: "satisfies" }); + } + } + + const byId = new Map(nodes.map((n) => [n.id, n])); + const validEdges = edges.filter((e) => byId.has(e.from) && byId.has(e.to)); + + // Longest-path layering, top-down. Guard against cycles (they are a + // compile error, but the graph still renders while one exists). + const depth = new Map(nodes.map((n) => [n.id, 0])); + for (let pass = 0; pass < nodes.length + 1; pass++) { + let changed = false; + for (const e of validEdges) { + const want = depth.get(e.from) + 1; + if (want > depth.get(e.to) && want <= nodes.length) { + depth.set(e.to, want); + changed = true; + } + } + if (!changed) break; + } + + const KIND_ORDER = { agent: 0, profile: 1, interface: 2, skill: 3, capability: 4 }; + const rows = []; + for (const node of nodes) { + const d = depth.get(node.id); + (rows[d] = rows[d] || []).push(node); + } + for (const row of rows) { + if (row) row.sort((a, b) => (KIND_ORDER[a.kind] - KIND_ORDER[b.kind]) || a.id.localeCompare(b.id)); + } + + const css = getComputedStyle(document.documentElement); + const color = (v) => css.getPropertyValue(v).trim(); + const charW = 7, padX = 12, nodeH = 40, rowGap = 104, gap = 22; + const pos = new Map(); + let maxRowWidth = 0; + rows.forEach((row) => { + if (!row) return; + const width = row.reduce((w, n) => w + Math.max(90, shortName(n.id).length * charW + padX * 2) + gap, -gap); + maxRowWidth = Math.max(maxRowWidth, width); + }); + const svgWidth = Math.max(560, maxRowWidth + 48); + rows.forEach((row, d) => { + if (!row) return; + const rowWidth = row.reduce((w, n) => w + Math.max(90, shortName(n.id).length * charW + padX * 2) + gap, -gap); + let x = (svgWidth - rowWidth) / 2; + for (const node of row) { + const w = Math.max(90, shortName(node.id).length * charW + padX * 2); + pos.set(node.id, { x, y: 36 + d * rowGap, w, h: nodeH }); + x += w + gap; + } + }); + const svgHeight = 36 + rows.length * rowGap + 20; + + const svgParts = []; + for (const e of validEdges) { + const a = pos.get(e.from), b = pos.get(e.to); + if (!a || !b) continue; + const x1 = a.x + a.w / 2, y1 = a.y + a.h; + const x2 = b.x + b.w / 2, y2 = b.y; + const stroke = color(EDGE_KIND_COLOR[e.kind] || "--text-dim"); + svgParts.push( + `` + + `${escapeHTML(`${e.from} ${e.kind} ${e.to}`)}`, + ); + } + for (const node of nodes) { + const p = pos.get(node.id); + const stroke = color(KIND_COLORS[node.kind] || "--text-dim"); + svgParts.push( + `${escapeHTML(node.id)}` + + `` + + `${node.kind}` + + `${escapeHTML(shortName(node.id))}` + + ``, + ); + } + els.graph.setAttribute("viewBox", `0 0 ${svgWidth} ${svgHeight}`); + els.graph.setAttribute("width", svgWidth); + els.graph.setAttribute("height", svgHeight); + els.graph.innerHTML = svgParts.join(""); + + els.graphLegend.innerHTML = + Object.entries(KIND_COLORS).map(([kind, v]) => + `${kind}`).join("") + + `embeds` + + `satisfies (structural)`; +} + +/* --------------------------------------------------------------- bundle */ + +function renderBundle(result) { + const agents = result.agents || []; + els.bundleAgent.innerHTML = ""; + for (const agent of agents) { + const opt = document.createElement("option"); + opt.value = agent.id; + opt.textContent = agent.id; + els.bundleAgent.appendChild(opt); + } + if (!agents.some((a) => a.id === state.activeAgent)) { + state.activeAgent = agents.length ? agents[0].id : null; + } + if (state.activeAgent) els.bundleAgent.value = state.activeAgent; + showBundle(); +} + +function showBundle() { + const agent = (state.result?.agents || []).find((a) => a.id === state.activeAgent); + els.bundleView.innerHTML = agent ? highlightJSON(agent.bundle) : ""; +} + +/* ----------------------------------------------------------------- tabs */ + +function initTabs() { + for (const button of document.querySelectorAll(".tabs button")) { + button.addEventListener("click", () => { + for (const b of document.querySelectorAll(".tabs button")) b.classList.toggle("active", b === button); + for (const tab of document.querySelectorAll(".tab")) { + tab.classList.toggle("active", tab.id === "tab-" + button.dataset.tab); + } + }); + } + els.bundleAgent.addEventListener("change", () => { + state.activeAgent = els.bundleAgent.value; + showBundle(); + }); +} + +/* ------------------------------------------------------------- examples */ + +function loadExample(name) { + const example = state.examples.find((e) => e.name === name); + if (!example) return; + state.example = name; + state.files = new Map(Object.entries(example.files)); + state.activeArtifact = null; + state.activeAgent = null; + bethReset(name); + const paths = [...state.files.keys()].sort(); + openFile(paths.find((p) => p.includes("agent")) ?? paths[0]); + scheduleCompile(); +} + +function initExamples() { + for (const example of state.examples) { + const opt = document.createElement("option"); + opt.value = example.name; + opt.textContent = example.title; + opt.title = example.description; + els.exampleSelect.appendChild(opt); + } + els.exampleSelect.addEventListener("change", () => loadExample(els.exampleSelect.value)); + els.reset.addEventListener("click", () => loadExample(els.exampleSelect.value)); + els.emitArd.addEventListener("change", scheduleCompile); +} + +/* ---------------------------------------------------------------- share */ + +const b64url = { + encode: (buf) => btoa(String.fromCharCode(...new Uint8Array(buf))) + .replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""), + decode: (s) => Uint8Array.from(atob(s.replace(/-/g, "+").replace(/_/g, "/")), (c) => c.charCodeAt(0)), +}; + +async function shareLink() { + const payload = JSON.stringify({ example: state.example, files: Object.fromEntries(state.files) }); + const stream = new Blob([payload]).stream().pipeThrough(new CompressionStream("gzip")); + const compressed = await new Response(stream).arrayBuffer(); + const url = new URL(location.href); + url.hash = "code=" + b64url.encode(compressed); + history.replaceState(null, "", url); + try { + await navigator.clipboard.writeText(url.href); + toast("Link copied to clipboard"); + } catch { + toast("Link is in the address bar"); + } +} + +async function restoreFromHash() { + const match = location.hash.match(/^#code=(.+)$/); + if (!match) return false; + try { + const stream = new Blob([b64url.decode(match[1])]).stream() + .pipeThrough(new DecompressionStream("gzip")); + const payload = JSON.parse(await new Response(stream).text()); + state.example = payload.example ?? state.examples[0]?.name; + if (state.examples.some((e) => e.name === state.example)) { + els.exampleSelect.value = state.example; + } + state.files = new Map(Object.entries(payload.files)); + bethReset(state.example); + const paths = [...state.files.keys()].sort(); + openFile(paths.find((p) => p.includes("agent")) ?? paths[0]); + return true; + } catch { + return false; + } +} + +/* ----------------------------------------------------------------- boot */ + +async function loadCompiler() { + const go = new Go(); + const response = fetch("typeference.wasm"); + let instance; + try { + ({ instance } = await WebAssembly.instantiateStreaming(response, go.importObject)); + } catch { + // Some static hosts serve wasm with a generic MIME type. + const bytes = await (await fetch("typeference.wasm")).arrayBuffer(); + ({ instance } = await WebAssembly.instantiate(bytes, go.importObject)); + } + go.run(instance); // resolves only on exit; main blocks forever + for (let i = 0; i < 200 && !globalThis.TypeFerence; i++) { + await new Promise((resolve) => setTimeout(resolve, 25)); + } + if (!globalThis.TypeFerence) throw new Error("compiler did not initialize"); +} + +async function boot() { + initTheme(); + initEditor(); + initFilePane(); + initTabs(); + initBeth(); + els.share.addEventListener("click", shareLink); + + try { + const [examples] = await Promise.all([ + fetch("examples.json").then((r) => { + if (!r.ok) throw new Error(`examples.json: HTTP ${r.status}`); + return r.json(); + }), + loadCompiler(), + ]); + state.examples = examples.examples; + } catch (err) { + setStatus("error", `failed to load: ${escapeHTML(String(err))}`); + return; + } + + initExamples(); + state.ready = true; + const restored = await restoreFromHash(); + if (!restored) loadExample(state.examples[0].name); + else scheduleCompile(); +} + +boot(); diff --git a/web/playground/beth.js b/web/playground/beth.js new file mode 100644 index 0000000..eb04cd2 --- /dev/null +++ b/web/playground/beth.js @@ -0,0 +1,344 @@ +// BETH operator console (ADR-0011). The browser packs cells with the real +// eval.Pack (wasm), the human operator collects responses by copy/paste from +// real hosts, the console exports a byte-faithful run directory as .tar.gz, +// scoring happens locally with the CLI (keys stay in the operator's +// terminal), and the resulting scorecard.json renders here. This file makes +// no network requests. +"use strict"; + +const beth = { + run: null, // {files: {path: content}, manifest, cells: [{path, scenarioId, surface, prompt}]} + stale: false, // source edited after packing + exampleName: null, +}; + +/* ---------------------------------------------------------------- pack */ + +// Called by app.js: on example switch (reset) and on every successful +// compile after a pack (stale marker). +function bethReset(exampleName) { + beth.run = null; + beth.stale = false; + beth.exampleName = exampleName; + bethRenderScenarioList(); + document.getElementById("beth-step-collect").hidden = true; + document.getElementById("beth-step-export").hidden = true; + document.getElementById("beth-pack-status").textContent = ""; + document.getElementById("beth-cells").innerHTML = ""; +} + +function bethMarkStale() { + if (!beth.run || beth.stale) return; + beth.stale = true; + document.getElementById("beth-pack-status").textContent = + "source changed since this pack — repack to stay faithful (pasted responses are kept until then)"; +} + +function bethScenarios() { + const example = state.examples.find((e) => e.name === state.example); + return (example && example.scenarios) || {}; +} + +function bethRenderScenarioList() { + const names = Object.keys(bethScenarios()).sort(); + document.getElementById("beth-scenarios").textContent = names.length + ? `Scenarios shipped with this example: ${names.join(", ")}` + : "This example ships no scenarios."; + document.getElementById("beth-pack-btn").disabled = names.length === 0; +} + +function bethPack() { + const scenarios = bethScenarios(); + const status = document.getElementById("beth-pack-status"); + const result = globalThis.TypeFerence.pack({ + files: Object.fromEntries(state.files), + sourceName: state.example || "src", + scenarios, + }); + if (!result.ok) { + status.textContent = result.error; + return; + } + const manifest = JSON.parse(result.files["manifest.json"]); + const cells = manifest.cells.map((cell) => ({ + path: cell.path, + scenarioId: cell.scenario, + surface: cell.surface, + prompt: result.files[cell.path + "/PROMPT.txt"] ?? "", + })); + beth.run = { files: result.files, manifest, cells }; + beth.stale = false; + status.textContent = + `packed ${cells.length} cells (${manifest.cells.length / manifest.surfaces.length} scenarios × ` + + `${manifest.surfaces.length} surfaces) · source ${manifest.sourceDigest.slice(0, 19)}…`; + bethRenderCells(); + document.getElementById("beth-step-collect").hidden = false; + document.getElementById("beth-step-export").hidden = false; + bethRefreshExport(); +} + +/* -------------------------------------------------------------- collect */ + +function bethRenderCells() { + const container = document.getElementById("beth-cells"); + container.innerHTML = ""; + const byScenario = new Map(); + for (const cell of beth.run.cells) { + if (!byScenario.has(cell.scenarioId)) byScenario.set(cell.scenarioId, []); + byScenario.get(cell.scenarioId).push(cell); + } + for (const [scenarioId, cells] of byScenario) { + const group = document.createElement("div"); + group.className = "beth-scenario"; + const heading = document.createElement("h4"); + heading.textContent = scenarioId; + group.appendChild(heading); + const task = document.createElement("details"); + task.innerHTML = "Prompt (identical for every surface)"; + const pre = document.createElement("pre"); + pre.textContent = cells[0].prompt; + task.appendChild(pre); + group.appendChild(task); + const grid = document.createElement("div"); + grid.className = "beth-cell-grid"; + for (const cell of cells) { + const card = document.createElement("div"); + card.className = "beth-cell"; + card.dataset.path = cell.path; + card.innerHTML = ` +
+ ${escapeHTML(cell.surface)} + + +
+ +
+ + +
`; + card.querySelector(".copy-prompt").addEventListener("click", async () => { + await navigator.clipboard.writeText(cell.prompt); + toast(`Prompt copied — run it on a ${cell.surface} host, then paste the response`); + }); + card.querySelector("textarea").addEventListener("input", () => { + card.querySelector(".collected").hidden = !card.querySelector("textarea").value.trim(); + bethRefreshExport(); + }); + grid.appendChild(card); + } + group.appendChild(grid); + container.appendChild(group); + } +} + +function bethCollectedCount() { + let n = 0; + for (const card of document.querySelectorAll(".beth-cell")) { + if (card.querySelector("textarea").value.trim()) n++; + } + return n; +} + +function bethRefreshExport() { + if (!beth.run) return; + const collected = bethCollectedCount(); + const total = beth.run.cells.length; + document.getElementById("beth-export-status").textContent = + `${collected}/${total} cells collected — export includes every cell either way; ` + + `uncollected cells score as no-response`; + const dirName = `beth-run-${beth.exampleName || "playground"}`; + document.getElementById("beth-score-cmd").textContent = + `tar -xzf ${dirName}.tar.gz\n` + + `typeference equivalence score ${dirName} # offline: emits judge payloads\n` + + `typeference equivalence score ${dirName} --live # judges via ANTHROPIC_API_KEY (your terminal, your key)`; +} + +/* --------------------------------------------------------------- export */ + +// Minimal ustar writer. mtime is fixed at 0 so identical runs produce +// byte-identical archives — the house rule applies to exports too. +function tarArchive(files) { + const encoder = new TextEncoder(); + const blocks = []; + const writeString = (buf, at, max, value) => { + const bytes = encoder.encode(value); + if (bytes.length > max) throw new Error(`tar field overflow: ${value}`); + buf.set(bytes, at); + }; + const writeOctal = (buf, at, width, value) => { + writeString(buf, at, width - 1, value.toString(8).padStart(width - 1, "0")); + }; + for (const [path, content] of files) { + const data = typeof content === "string" ? encoder.encode(content) : content; + let name = path, prefix = ""; + if (encoder.encode(name).length > 100) { + const cut = path.slice(0, 155).lastIndexOf("/"); + if (cut <= 0) throw new Error(`path too long for ustar: ${path}`); + prefix = path.slice(0, cut); + name = path.slice(cut + 1); + if (encoder.encode(name).length > 100 || encoder.encode(prefix).length > 155) { + throw new Error(`path too long for ustar: ${path}`); + } + } + const header = new Uint8Array(512); + writeString(header, 0, 100, name); + writeOctal(header, 100, 8, 0o644); // mode + writeOctal(header, 108, 8, 0); // uid + writeOctal(header, 116, 8, 0); // gid + writeOctal(header, 124, 12, data.length); + writeOctal(header, 136, 12, 0); // mtime: fixed for determinism + header.fill(0x20, 148, 156); // checksum field counts as spaces + header[156] = 0x30; // typeflag '0': regular file + writeString(header, 257, 6, "ustar"); + header[263] = 0x30; header[264] = 0x30; // version "00" + writeString(header, 345, 155, prefix); + let sum = 0; + for (const byte of header) sum += byte; + writeString(header, 148, 7, sum.toString(8).padStart(6, "0") + "\0"); + blocks.push(header, data); + const overhang = data.length % 512; + if (overhang) blocks.push(new Uint8Array(512 - overhang)); + } + blocks.push(new Uint8Array(1024)); // end-of-archive + return new Blob(blocks, { type: "application/x-tar" }); +} + +// Assembles the run directory (pack output plus collected responses) and +// returns { name, blob } with the gzipped tar. +async function bethBuildArchive() { + const dirName = `beth-run-${beth.exampleName || "playground"}`; + const files = new Map(); + for (const [path, content] of Object.entries(beth.run.files).sort((a, b) => (a[0] < b[0] ? -1 : 1))) { + files.set(`${dirName}/${path}`, content); + } + for (const card of document.querySelectorAll(".beth-cell")) { + const response = card.querySelector("textarea").value; + if (!response.trim()) continue; + const base = `${dirName}/${card.dataset.path}`; + files.set(`${base}/response.md`, response.endsWith("\n") ? response : response + "\n"); + const host = card.querySelector(".host").value.trim(); + const model = card.querySelector(".model").value.trim(); + if (host || model) { + const runtime = {}; + if (host) runtime.host = host; + if (model) runtime.model = model; + files.set(`${base}/runtime.json`, JSON.stringify(runtime) + "\n"); + } + } + const blob = await new Response( + tarArchive(files).stream().pipeThrough(new CompressionStream("gzip")), + ).blob(); + return { name: `${dirName}.tar.gz`, blob, fileCount: files.size }; +} + +async function bethExport() { + if (!beth.run) return; + const archive = await bethBuildArchive(); + const url = URL.createObjectURL(archive.blob); + const anchor = document.createElement("a"); + anchor.href = url; + anchor.download = archive.name; + anchor.click(); + URL.revokeObjectURL(url); + toast(`Exported ${archive.fileCount} files — score it locally`); +} + +/* ------------------------------------------------------------ scorecard */ + +function bethRenderScorecard(card) { + const el = document.getElementById("beth-scorecard"); + if (typeof card !== "object" || card === null || !Array.isArray(card.adherence)) { + el.innerHTML = `

Not a BETH scorecard.json (missing adherence array).

`; + return; + } + const parts = []; + parts.push(`
` + + `${card.passed ? "PASSED" : "NOT PASSED"} — ${card.cells.judged}/${card.cells.total} cells judged` + + `${card.judgeModel ? ` · judge: ${escapeHTML(card.judgeModel)}` : ""}
`); + + parts.push(`
`); + for (const row of card.adherence) { + const ratio = row.judged ? row.passed / row.judged : 0; + parts.push(`
${escapeHTML(row.surface)}` + + `` + + `${row.passed}/${row.judged}
`); + } + const agreement = card.agreement || { agreed: 0, comparable: 0 }; + parts.push(`
agreement` + + `` + + `${agreement.agreed}/${agreement.comparable}
`); + parts.push(`
`); + + for (const scenario of card.scenarios || []) { + const surfaces = (scenario.cells || []).map((c) => c.surface); + parts.push(`

${escapeHTML(scenario.id)}

`); + for (const cell of scenario.cells || []) { + parts.push(``); + } + parts.push(``); + for (const item of scenario.rubric || []) { + parts.push(``); + const bySurface = new Map((item.verdicts || []).map((v) => [v.surface, v])); + for (const surface of surfaces) { + const verdict = bySurface.get(surface); + if (!verdict) parts.push(``); + else parts.push(``); + } + parts.push(``); + } + parts.push(`
` + + `${escapeHTML(cell.surface)}${cell.status !== "judged" ? "*" : ""}
${escapeHTML(item.id)}` + + `${verdict.passed ? "✓" : "✗"}
`); + } + + if ((card.divergences || []).length) { + parts.push(`

Divergences

`); + for (const divergence of card.divergences) { + parts.push(`
${escapeHTML(divergence.scenario)} · ` + + `${escapeHTML(divergence.rubricItem)}
    `); + for (const verdict of divergence.verdicts || []) { + parts.push(`
  • ${verdict.passed ? "✓" : "✗"} ` + + `${escapeHTML(verdict.surface)} — ${escapeHTML(verdict.reasoning || "")}
  • `); + } + parts.push(`
`); + } + parts.push(`
`); + } else { + parts.push(`

No divergences among comparable rubric items.

`); + } + parts.push(`

A scorecard is one observation per surface at one point in time, not a proof of behavioral equivalence (ADR-0009). Cells marked * were not judged.

`); + el.innerHTML = parts.join(""); +} + +function bethReadScorecard(file) { + const reader = new FileReader(); + reader.onload = () => { + try { + bethRenderScorecard(JSON.parse(reader.result)); + } catch { + document.getElementById("beth-scorecard").innerHTML = + `

Could not parse that file as JSON.

`; + } + }; + reader.readAsText(file); +} + +/* ----------------------------------------------------------------- init */ + +function initBeth() { + document.getElementById("beth-pack-btn").addEventListener("click", bethPack); + document.getElementById("beth-export-btn").addEventListener("click", bethExport); + const drop = document.getElementById("beth-drop"); + const fileInput = document.getElementById("beth-scorecard-file"); + drop.addEventListener("click", () => fileInput.click()); + fileInput.addEventListener("change", () => { + if (fileInput.files[0]) bethReadScorecard(fileInput.files[0]); + }); + drop.addEventListener("dragover", (e) => { e.preventDefault(); drop.classList.add("over"); }); + drop.addEventListener("dragleave", () => drop.classList.remove("over")); + drop.addEventListener("drop", (e) => { + e.preventDefault(); + drop.classList.remove("over"); + if (e.dataTransfer.files[0]) bethReadScorecard(e.dataTransfer.files[0]); + }); +} diff --git a/web/playground/index.html b/web/playground/index.html new file mode 100644 index 0000000..01e8571 --- /dev/null +++ b/web/playground/index.html @@ -0,0 +1,128 @@ + + + + + + + + TypeFerence Playground + + + + + + + + + +
+
+ +

TypeFerence Playground

+
+ + + + The real Go compiler, in your tab. Nothing you type leaves the browser. + + + + GitHub +
+ +
+ + +
+
+ + +
+ +
+ +
+ +
+ +
+
+
+
+
+
+
+
+ + resolved composition: every field with its provenance +
+
+
+
+
+

BETH — the Behavioral Equivalence Test Harness. Pack lays out one + cell per scenario × compiled surface; you are the operator: run each prompt in a real + host, paste the final response back, export the run, and judge it locally with your own keys in + your own terminal. This page never asks for a credential and never talks to any server. A + scorecard is one observation per surface, not a proof + (ADR-0009).

+ +
+

1 Pack scenarios × surfaces

+
+
+ + +
+
+ + + + + +
+

4 Scorecard

+ +
+
+
+
+
+
+ + + + diff --git a/web/playground/memfs.js b/web/playground/memfs.js new file mode 100644 index 0000000..54a445c --- /dev/null +++ b/web/playground/memfs.js @@ -0,0 +1,319 @@ +// In-memory filesystem for the Go js/wasm runtime. +// +// Go's syscall layer on js/wasm delegates every file operation to a global +// `fs` object with Node.js-style callback APIs (see $GOROOT/src/syscall/ +// fs_js.go). This file provides that object backed by a plain in-memory +// tree, so the unmodified TypeFerence compiler can read sources and write +// artifacts entirely inside the browser tab. It must be loaded BEFORE +// wasm_exec.js, which only installs its non-functional stub when no `fs` +// global exists. +// +// Error objects carry a `code` property from Go's errnoByCode table +// (ENOENT, EEXIST, EISDIR, ...); Go panics on codes it does not know. +"use strict"; +(() => { + const decoder = new TextDecoder("utf-8"); + + const S_IFDIR = 0o40000; + const S_IFREG = 0o100000; + + const constants = { + O_RDONLY: 0, + O_WRONLY: 1, + O_RDWR: 2, + O_CREAT: 64, + O_EXCL: 128, + O_TRUNC: 512, + O_APPEND: 1024, + O_DIRECTORY: 65536, + }; + + let nextIno = 1; + const makeDir = () => ({ dir: true, ino: nextIno++, entries: new Map(), mtimeMs: Date.now() }); + const makeFile = () => ({ dir: false, ino: nextIno++, data: new Uint8Array(0), mtimeMs: Date.now() }); + + let root = makeDir(); + + const errno = (code) => { + const err = new Error(code); + err.code = code; + return err; + }; + + // "/a/./b/../c" -> ["a", "c"]; relative paths resolve from "/". + const segmentsOf = (path) => { + const out = []; + for (const part of String(path).split("/")) { + if (part === "" || part === ".") continue; + if (part === "..") out.pop(); + else out.push(part); + } + return out; + }; + + const lookup = (path) => { + let node = root; + for (const segment of segmentsOf(path)) { + if (!node.dir) return null; + node = node.entries.get(segment); + if (!node) return null; + } + return node; + }; + + // Returns [parentDirNode, finalName] or null when an ancestor is missing. + const lookupParent = (path) => { + const segments = segmentsOf(path); + if (segments.length === 0) return null; + let node = root; + for (const segment of segments.slice(0, -1)) { + if (!node.dir) return null; + node = node.entries.get(segment); + if (!node) return null; + } + if (!node.dir) return null; + return [node, segments[segments.length - 1]]; + }; + + const statOf = (node) => ({ + dev: 1, + ino: node.ino, + mode: (node.dir ? S_IFDIR : S_IFREG) | 0o644, + nlink: 1, + uid: 0, + gid: 0, + rdev: 0, + size: node.dir ? 0 : node.data.length, + blksize: 4096, + blocks: node.dir ? 0 : Math.ceil(node.data.length / 512), + atimeMs: node.mtimeMs, + mtimeMs: node.mtimeMs, + ctimeMs: node.mtimeMs, + isDirectory() { return node.dir; }, + isFile() { return !node.dir; }, + isSymbolicLink() { return false; }, + }); + + // Open file descriptors. 0/1/2 are reserved for the standard streams. + const fds = new Map(); + let nextFd = 100; + + // Captured standard output, mirrored to the console line by line. + const output = { stdout: "", stderr: "" }; + const lineBuf = { 1: "", 2: "" }; + const writeStream = (fd, bytes) => { + const text = decoder.decode(bytes); + if (fd === 1) output.stdout += text; + else output.stderr += text; + lineBuf[fd] += text; + let nl; + while ((nl = lineBuf[fd].indexOf("\n")) >= 0) { + const line = lineBuf[fd].slice(0, nl); + lineBuf[fd] = lineBuf[fd].slice(nl + 1); + (fd === 1 ? console.log : console.error)(line); + } + return bytes.length; + }; + + const writeAt = (node, bytes, position) => { + const end = position + bytes.length; + if (end > node.data.length) { + const grown = new Uint8Array(end); + grown.set(node.data); + node.data = grown; + } + node.data.set(bytes, position); + node.mtimeMs = Date.now(); + return bytes.length; + }; + + globalThis.fs = { + constants, + + writeSync(fd, buf) { + if (fd === 1 || fd === 2) return writeStream(fd, buf); + const handle = fds.get(fd); + if (!handle || handle.node.dir) throw errno(fd in fds ? "EISDIR" : "EBADF"); + const n = writeAt(handle.node, buf, handle.pos); + handle.pos += n; + return n; + }, + + write(fd, buf, offset, length, position, callback) { + const bytes = buf.subarray(offset, offset + length); + if (fd === 1 || fd === 2) { + callback(null, writeStream(fd, bytes)); + return; + } + const handle = fds.get(fd); + if (!handle) return callback(errno("EBADF")); + if (handle.node.dir) return callback(errno("EISDIR")); + const at = position === null || position === undefined ? handle.pos : position; + const n = writeAt(handle.node, bytes, at); + if (position === null || position === undefined) handle.pos += n; + callback(null, n); + }, + + read(fd, buffer, offset, length, position, callback) { + const handle = fds.get(fd); + if (!handle) return callback(errno("EBADF")); + if (handle.node.dir) return callback(errno("EISDIR")); + const at = position === null || position === undefined ? handle.pos : position; + const available = Math.max(0, handle.node.data.length - at); + const n = Math.min(length, available); + buffer.set(handle.node.data.subarray(at, at + n), offset); + if (position === null || position === undefined) handle.pos += n; + callback(null, n); + }, + + open(path, flags, mode, callback) { + let node = lookup(path); + if (flags & constants.O_CREAT) { + if (node && flags & constants.O_EXCL) return callback(errno("EEXIST")); + if (!node) { + const parent = lookupParent(path); + if (!parent) return callback(errno("ENOENT")); + node = makeFile(); + parent[0].entries.set(parent[1], node); + } + } + if (!node) return callback(errno("ENOENT")); + if (flags & constants.O_DIRECTORY && !node.dir) return callback(errno("ENOTDIR")); + if (flags & constants.O_TRUNC && !node.dir) { + node.data = new Uint8Array(0); + node.mtimeMs = Date.now(); + } + const fd = nextFd++; + fds.set(fd, { + node, + pos: flags & constants.O_APPEND && !node.dir ? node.data.length : 0, + }); + callback(null, fd); + }, + + close(fd, callback) { + fds.delete(fd); + callback(null); + }, + + stat(path, callback) { + const node = lookup(path); + if (!node) return callback(errno("ENOENT")); + callback(null, statOf(node)); + }, + + lstat(path, callback) { + globalThis.fs.stat(path, callback); + }, + + fstat(fd, callback) { + const handle = fds.get(fd); + if (!handle) return callback(errno("EBADF")); + callback(null, statOf(handle.node)); + }, + + readdir(path, callback) { + const node = lookup(path); + if (!node) return callback(errno("ENOENT")); + if (!node.dir) return callback(errno("ENOTDIR")); + callback(null, [...node.entries.keys()]); + }, + + mkdir(path, perm, callback) { + if (lookup(path)) return callback(errno("EEXIST")); + const parent = lookupParent(path); + if (!parent) return callback(errno("ENOENT")); + parent[0].entries.set(parent[1], makeDir()); + callback(null); + }, + + rmdir(path, callback) { + const parent = lookupParent(path); + const node = parent && parent[0].entries.get(parent[1]); + if (!node) return callback(errno("ENOENT")); + if (!node.dir) return callback(errno("ENOTDIR")); + if (node.entries.size > 0) return callback(errno("ENOTEMPTY")); + parent[0].entries.delete(parent[1]); + callback(null); + }, + + unlink(path, callback) { + const parent = lookupParent(path); + const node = parent && parent[0].entries.get(parent[1]); + if (!node) return callback(errno("ENOENT")); + if (node.dir) return callback(errno("EISDIR")); + parent[0].entries.delete(parent[1]); + callback(null); + }, + + rename(from, to, callback) { + const fromParent = lookupParent(from); + const node = fromParent && fromParent[0].entries.get(fromParent[1]); + if (!node) return callback(errno("ENOENT")); + const toParent = lookupParent(to); + if (!toParent) return callback(errno("ENOENT")); + fromParent[0].entries.delete(fromParent[1]); + toParent[0].entries.set(toParent[1], node); + callback(null); + }, + + truncate(path, length, callback) { + const node = lookup(path); + if (!node) return callback(errno("ENOENT")); + if (node.dir) return callback(errno("EISDIR")); + const data = new Uint8Array(length); + data.set(node.data.subarray(0, Math.min(length, node.data.length))); + node.data = data; + node.mtimeMs = Date.now(); + callback(null); + }, + + ftruncate(fd, length, callback) { + const handle = fds.get(fd); + if (!handle) return callback(errno("EBADF")); + if (handle.node.dir) return callback(errno("EISDIR")); + const data = new Uint8Array(length); + data.set(handle.node.data.subarray(0, Math.min(length, handle.node.data.length))); + handle.node.data = data; + handle.node.mtimeMs = Date.now(); + callback(null); + }, + + utimes(path, atime, mtime, callback) { + const node = lookup(path); + if (!node) return callback(errno("ENOENT")); + node.mtimeMs = mtime * 1000; + callback(null); + }, + + chmod(path, mode, callback) { callback(lookup(path) ? null : errno("ENOENT")); }, + fchmod(fd, mode, callback) { callback(fds.has(fd) ? null : errno("EBADF")); }, + chown(path, uid, gid, callback) { callback(lookup(path) ? null : errno("ENOENT")); }, + fchown(fd, uid, gid, callback) { callback(fds.has(fd) ? null : errno("EBADF")); }, + lchown(path, uid, gid, callback) { callback(lookup(path) ? null : errno("ENOENT")); }, + fsync(fd, callback) { callback(null); }, + link(from, to, callback) { callback(errno("EPERM")); }, + symlink(from, to, callback) { callback(errno("EPERM")); }, + readlink(path, callback) { callback(errno("EINVAL")); }, + }; + + // syscall.Open resolves every opened path through this. + globalThis.path = { + resolve(...parts) { + return "/" + segmentsOf(parts.join("/")).join("/"); + }, + }; + + // Test and debugging surface; the playground UI reads captured output. + globalThis.__memfs = { + output, + reset() { + root = makeDir(); + fds.clear(); + output.stdout = ""; + output.stderr = ""; + lineBuf[1] = ""; + lineBuf[2] = ""; + }, + }; +})(); diff --git a/web/playground/style.css b/web/playground/style.css new file mode 100644 index 0000000..db815fe --- /dev/null +++ b/web/playground/style.css @@ -0,0 +1,406 @@ +/* TypeFerence Playground — no dependencies, light/dark via custom properties. */ + +:root { + --bg: #f4f5f8; + --panel: #ffffff; + --panel-2: #eef0f4; + --border: #d8dce4; + --text: #1b2330; + --text-dim: #5b6472; + --accent: #4f46e5; + --accent-dim: #eef2ff; + --error: #b91c1c; + --error-bg: #fef2f2; + --ok: #047857; + --sel: #dbe3ff; + /* syntax */ + --syn-key: #7c3aed; + --syn-str: #0f766e; + --syn-num: #b45309; + --syn-comment: #94a3b8; + --syn-punct: #64748b; + /* graph node kinds */ + --kind-agent: #4f46e5; + --kind-profile: #0891b2; + --kind-skill: #059669; + --kind-capability: #d97706; + --kind-interface: #9333ea; +} +[data-theme="dark"] { + --bg: #0d1117; + --panel: #161b22; + --panel-2: #1c2330; + --border: #2d3543; + --text: #e6edf3; + --text-dim: #8b949e; + --accent: #818cf8; + --accent-dim: #1e2242; + --error: #f87171; + --error-bg: #2c1516; + --ok: #34d399; + --sel: #26304d; + --syn-key: #c4b5fd; + --syn-str: #5eead4; + --syn-num: #fbbf24; + --syn-comment: #586374; + --syn-punct: #8b949e; + --kind-agent: #818cf8; + --kind-profile: #22d3ee; + --kind-skill: #34d399; + --kind-capability: #fbbf24; + --kind-interface: #c084fc; +} + +* { box-sizing: border-box; } +html, body { height: 100%; margin: 0; } +body { + display: flex; + flex-direction: column; + font: 14px/1.45 ui-sans-serif, system-ui, "Segoe UI", sans-serif; + background: var(--bg); + color: var(--text); +} +code, pre, textarea, select, .mono { font-family: ui-monospace, "Cascadia Code", Consolas, monospace; } + +/* ---------- header ---------- */ +header { + display: flex; + align-items: center; + gap: 12px; + padding: 8px 14px; + background: var(--panel); + border-bottom: 1px solid var(--border); + flex-wrap: wrap; +} +.brand { display: flex; align-items: center; gap: 9px; } +.brand h1 { font-size: 16px; margin: 0; font-weight: 650; letter-spacing: .1px; } +.brand h1 span { color: var(--accent); font-weight: 500; } +.tagline { color: var(--text-dim); font-size: 12px; } +.spacer { flex: 1; } +.control { display: inline-flex; align-items: center; gap: 6px; color: var(--text-dim); font-size: 12.5px; } +.control select { + background: var(--panel-2); + color: var(--text); + border: 1px solid var(--border); + border-radius: 6px; + padding: 4px 6px; + font-size: 12.5px; +} +.control.checkbox input { accent-color: var(--accent); } +button.ghost, a.ghost { + background: none; + border: 1px solid var(--border); + border-radius: 6px; + color: var(--text); + padding: 4px 10px; + font-size: 12.5px; + cursor: pointer; + text-decoration: none; +} +button.ghost:hover, a.ghost:hover { border-color: var(--accent); color: var(--accent); } + +/* ---------- layout ---------- */ +main { + flex: 1; + display: grid; + grid-template-columns: 190px minmax(320px, 43fr) minmax(320px, 57fr); + min-height: 0; +} +@media (max-width: 900px) { + main { grid-template-columns: 150px 1fr; grid-template-rows: 45vh 45vh; } + #output-pane { grid-column: 1 / -1; } +} + +/* ---------- file pane ---------- */ +#file-pane { + background: var(--panel); + border-right: 1px solid var(--border); + overflow-y: auto; + min-height: 0; +} +.pane-head { + display: flex; + justify-content: space-between; + align-items: center; + padding: 8px 10px 4px; + font-size: 11px; + font-weight: 650; + text-transform: uppercase; + letter-spacing: .8px; + color: var(--text-dim); +} +.pane-head .ghost { padding: 0 7px; font-size: 14px; line-height: 1.4; } +#file-list { list-style: none; margin: 0; padding: 0 0 10px; } +#file-list li { + display: flex; + align-items: center; + justify-content: space-between; + padding: 3px 10px 3px 14px; + font-size: 12.5px; + font-family: ui-monospace, Consolas, monospace; + cursor: pointer; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + border-left: 2px solid transparent; +} +#file-list li:hover { background: var(--panel-2); } +#file-list li.active { background: var(--sel); border-left-color: var(--accent); } +#file-list li .del { visibility: hidden; border: 0; background: none; color: var(--text-dim); cursor: pointer; font-size: 12px; padding: 0 2px; } +#file-list li:hover .del { visibility: visible; } +#file-list li .del:hover { color: var(--error); } +#file-list li.dir { + cursor: default; + color: var(--text-dim); + font-size: 11px; + padding-top: 7px; + text-transform: none; + font-family: ui-sans-serif, system-ui, sans-serif; +} +#file-list li.dir:hover { background: none; } + +/* ---------- editor ---------- */ +#editor-pane { display: flex; flex-direction: column; min-width: 0; min-height: 0; border-right: 1px solid var(--border); } +.editor { position: relative; flex: 1; min-height: 0; background: var(--panel); } +.editor pre, .editor textarea { + position: absolute; + inset: 0; + margin: 0; + padding: 12px 14px; + font-size: 13px; + line-height: 1.5; + tab-size: 2; + white-space: pre; + overflow: auto; + border: 0; +} +.editor pre { pointer-events: none; } +.editor pre code { display: block; min-height: 100%; } +.editor textarea { + width: 100%; + height: 100%; + resize: none; + background: transparent; + color: transparent; + caret-color: var(--text); + outline: none; + overflow: auto; +} +.editor textarea::selection { background: var(--sel); color: transparent; } + +#diagnostics { + border-top: 1px solid var(--border); + background: var(--error-bg); + color: var(--error); + padding: 8px 14px; + font-size: 12.5px; + font-family: ui-monospace, Consolas, monospace; + white-space: pre-wrap; + max-height: 30%; + overflow-y: auto; +} + +/* ---------- output pane ---------- */ +#output-pane { display: flex; flex-direction: column; min-width: 0; min-height: 0; background: var(--panel); } +.tabs { display: flex; gap: 2px; border-bottom: 1px solid var(--border); padding: 0 8px; background: var(--panel); } +.tabs button { + background: none; + border: 0; + border-bottom: 2px solid transparent; + color: var(--text-dim); + padding: 8px 12px; + font-size: 13px; + cursor: pointer; +} +.tabs button:hover { color: var(--text); } +.tabs button.active { color: var(--accent); border-bottom-color: var(--accent); font-weight: 600; } +.tab { display: none; flex: 1; min-height: 0; } +.tab.active { display: flex; } +#output-pane.stale .tab { opacity: .45; } + +#tab-artifacts { display: none; } +#tab-artifacts.active { display: flex; } +#artifact-list { + width: 240px; + overflow-y: auto; + border-right: 1px solid var(--border); + padding: 6px 0; + flex-shrink: 0; +} +#artifact-list .dir { padding: 6px 10px 2px; font-size: 11px; color: var(--text-dim); } +#artifact-list .file { + padding: 2px 10px 2px 18px; + font-size: 12px; + font-family: ui-monospace, Consolas, monospace; + cursor: pointer; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + border-left: 2px solid transparent; +} +#artifact-list .file:hover { background: var(--panel-2); } +#artifact-list .file.active { background: var(--sel); border-left-color: var(--accent); } +#artifact-view, #bundle-view { + flex: 1; + margin: 0; + padding: 12px 14px; + overflow: auto; + font-size: 12.5px; + line-height: 1.5; + min-width: 0; +} + +#tab-graph { flex-direction: column; } +#graph-legend { display: flex; gap: 14px; flex-wrap: wrap; padding: 8px 14px; font-size: 12px; color: var(--text-dim); border-bottom: 1px solid var(--border); } +#graph-legend .swatch { display: inline-block; width: 10px; height: 10px; border-radius: 3px; margin-right: 5px; vertical-align: -1px; } +#graph-legend .edge-sample { display: inline-block; width: 22px; height: 2px; margin-right: 5px; vertical-align: 3px; } +#graph-scroll { flex: 1; overflow: auto; } +#graph { display: block; margin: 0 auto; } +#graph .node rect { rx: 7px; fill: var(--panel-2); stroke-width: 1.6px; } +#graph .node text { font: 11.5px ui-monospace, Consolas, monospace; fill: var(--text); } +#graph .node .kind-label { font-size: 9px; fill: var(--text-dim); text-transform: uppercase; letter-spacing: .5px; } +#graph .edge { fill: none; stroke-width: 1.4px; opacity: .75; } +#graph .edge.satisfies { stroke-dasharray: 5 4; } +#graph .edge.skill, #graph .edge.binds, #graph .edge.capability, #graph .edge.requires { stroke-dasharray: 2 3; } +#graph .node:hover rect { fill: var(--sel); } + +#tab-bundle { flex-direction: column; } +.bundle-head { display: flex; align-items: center; gap: 12px; padding: 8px 14px; border-bottom: 1px solid var(--border); } +.hint { color: var(--text-dim); font-size: 12px; } + +/* ---------- BETH console ---------- */ +#tab-beth { flex-direction: column; } +.beth-scroll { flex: 1; overflow-y: auto; min-height: 0; padding: 12px 16px 24px; } +.beth-intro { margin: 0 0 14px; font-size: 13px; color: var(--text-dim); max-width: 85ch; } +.beth-intro b, .beth-intro a { color: var(--text); } +.beth-intro a { color: var(--accent); } +.beth-step { border: 1px solid var(--border); border-radius: 8px; background: var(--panel-2); padding: 12px 14px; margin-bottom: 12px; } +.beth-step h3 { margin: 0 0 8px; font-size: 13.5px; display: flex; align-items: center; gap: 8px; } +.step-n { + display: inline-flex; align-items: center; justify-content: center; + width: 20px; height: 20px; border-radius: 50%; + background: var(--accent); color: #fff; font-size: 11.5px; font-weight: 700; +} +.beth-row { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; margin-top: 8px; } +.beth-primary { + background: var(--accent); border: 0; border-radius: 6px; color: #fff; + padding: 7px 16px; font-size: 13px; font-weight: 600; cursor: pointer; +} +.beth-primary:hover { filter: brightness(1.1); } +.beth-primary:disabled { opacity: .55; cursor: default; } +.beth-cmd { + margin: 10px 0 0; padding: 10px 12px; background: var(--panel); + border: 1px solid var(--border); border-radius: 6px; + font-size: 12px; overflow-x: auto; white-space: pre; +} +.beth-error { color: var(--error); font-size: 13px; } + +.beth-scenario { margin-top: 12px; } +.beth-scenario h4 { margin: 0 0 6px; font-size: 12.5px; font-family: ui-monospace, Consolas, monospace; } +.beth-scenario details { margin-bottom: 8px; } +.beth-scenario summary { cursor: pointer; font-size: 12px; color: var(--text-dim); } +.beth-scenario details pre { + margin: 6px 0 0; padding: 8px 10px; background: var(--panel); + border: 1px solid var(--border); border-radius: 6px; + font-size: 12px; white-space: pre-wrap; +} +.beth-cell-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); gap: 10px; } +.beth-cell { background: var(--panel); border: 1px solid var(--border); border-radius: 7px; padding: 8px; } +.beth-cell .cell-head { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; } +.surface-chip { + font-size: 11px; font-weight: 650; text-transform: uppercase; letter-spacing: .5px; + background: var(--accent-dim); color: var(--accent); + border-radius: 5px; padding: 2px 7px; +} +.beth-cell .collected { margin-left: auto; color: var(--ok); font-weight: 700; } +.beth-cell textarea { + width: 100%; background: var(--panel-2); color: var(--text); + border: 1px solid var(--border); border-radius: 6px; + padding: 6px 8px; font-size: 12px; resize: vertical; +} +.beth-cell .cell-meta { display: flex; gap: 6px; margin-top: 6px; } +.beth-cell .cell-meta input { + flex: 1; min-width: 0; background: var(--panel-2); color: var(--text); + border: 1px solid var(--border); border-radius: 5px; + padding: 3px 7px; font-size: 11.5px; +} + +.beth-drop { + display: block; border: 1.5px dashed var(--border); border-radius: 7px; + padding: 14px; text-align: center; font-size: 12.5px; color: var(--text-dim); + cursor: pointer; +} +.beth-drop.over, .beth-drop:hover { border-color: var(--accent); color: var(--accent); } + +.beth-banner { + margin-top: 12px; padding: 8px 12px; border-radius: 7px; + font-weight: 700; font-size: 13px; +} +.beth-banner.pass { background: color-mix(in srgb, var(--ok) 14%, transparent); color: var(--ok); } +.beth-banner.fail { background: var(--error-bg); color: var(--error); } +.beth-metrics { margin-top: 10px; display: flex; flex-direction: column; gap: 6px; } +.beth-metric { display: flex; align-items: center; gap: 10px; font-size: 12.5px; } +.beth-metric .label { width: 90px; text-align: right; color: var(--text-dim); font-family: ui-monospace, Consolas, monospace; } +.beth-metric .bar { flex: 1; height: 8px; background: var(--panel); border: 1px solid var(--border); border-radius: 4px; overflow: hidden; } +.beth-metric .fill { display: block; height: 100%; background: var(--accent); } +.beth-metric .value { width: 46px; font-family: ui-monospace, Consolas, monospace; } +.beth-matrix { margin-top: 14px; overflow-x: auto; } +.beth-matrix h4 { margin: 0 0 6px; font-size: 12.5px; font-family: ui-monospace, Consolas, monospace; } +.beth-matrix table { border-collapse: collapse; font-size: 12.5px; } +.beth-matrix th, .beth-matrix td { border: 1px solid var(--border); padding: 4px 10px; text-align: center; } +.beth-matrix th { color: var(--text-dim); font-weight: 600; } +.beth-matrix .rubric-id { text-align: left; font-family: ui-monospace, Consolas, monospace; font-size: 11.5px; } +.beth-matrix td.pass { color: var(--ok); font-weight: 700; } +.beth-matrix td.fail { color: var(--error); font-weight: 700; } +.beth-matrix td.na { color: var(--text-dim); } +.beth-divergences { margin-top: 14px; } +.beth-divergences h4 { margin: 0 0 6px; font-size: 13px; } +.divergence { border: 1px solid var(--border); border-radius: 7px; background: var(--panel); padding: 8px 12px; margin-bottom: 8px; font-size: 12.5px; } +.divergence ul { margin: 6px 0 0; padding-left: 18px; } +.divergence .pass { color: var(--ok); font-weight: 600; } +.divergence .fail { color: var(--error); font-weight: 600; } + +/* ---------- status bar ---------- */ +footer { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 14px; + border-top: 1px solid var(--border); + background: var(--panel); + font-size: 12.5px; + color: var(--text-dim); + min-height: 30px; +} +footer.ok::before { content: "✓"; color: var(--ok); font-weight: 700; } +footer.error { color: var(--error); } +footer.error::before { content: "✕"; font-weight: 700; } +footer.loading::before { content: "…"; } +footer .digest { font-family: ui-monospace, Consolas, monospace; color: var(--text-dim); } +footer .digest b { color: var(--text); font-weight: 600; } + +/* ---------- syntax highlighting ---------- */ +.tok-key { color: var(--syn-key); } +.tok-str { color: var(--syn-str); } +.tok-num { color: var(--syn-num); } +.tok-comment { color: var(--syn-comment); font-style: italic; } +.tok-punct { color: var(--syn-punct); } +.tok-head { color: var(--syn-key); font-weight: 600; } + +/* toast */ +#toast { + position: fixed; + bottom: 46px; + left: 50%; + transform: translateX(-50%); + background: var(--text); + color: var(--bg); + border-radius: 8px; + padding: 8px 16px; + font-size: 13px; + opacity: 0; + transition: opacity .25s; + pointer-events: none; + z-index: 10; +} +#toast.show { opacity: .95; }