diff --git a/.github/screenshots/studio-concepts-v2.png b/.github/screenshots/studio-concepts-v2.png new file mode 100644 index 0000000..d0b3526 Binary files /dev/null and b/.github/screenshots/studio-concepts-v2.png differ diff --git a/.github/screenshots/studio-drafts-v2.png b/.github/screenshots/studio-drafts-v2.png new file mode 100644 index 0000000..6b71179 Binary files /dev/null and b/.github/screenshots/studio-drafts-v2.png differ diff --git a/.github/screenshots/studio-review-v2.png b/.github/screenshots/studio-review-v2.png new file mode 100644 index 0000000..e90056c Binary files /dev/null and b/.github/screenshots/studio-review-v2.png differ diff --git a/.github/screenshots/studio-runtime-v2.png b/.github/screenshots/studio-runtime-v2.png new file mode 100644 index 0000000..b44f3fb Binary files /dev/null and b/.github/screenshots/studio-runtime-v2.png differ diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a1ccc87..fa17f9b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -43,6 +43,10 @@ jobs: working-directory: apps/studio - run: bun run build working-directory: apps/studio + - run: bun x playwright install --with-deps chromium + working-directory: apps/studio + - run: bun run test:e2e + working-directory: apps/studio - run: uv run python scripts/embed_studio_assets.py --check - run: uv build - name: Rehearse example pack diff --git a/.gitignore b/.gitignore index cfcef33..d14c4bc 100644 --- a/.gitignore +++ b/.gitignore @@ -23,4 +23,6 @@ site/ apps/studio/node_modules/ apps/studio/dist/ apps/studio/*.tsbuildinfo +apps/studio/test-results/ +apps/studio/playwright-report/ *.termdb diff --git a/README.md b/README.md index 047aa5f..932c729 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ or translation provider. > TermKit is pre-alpha. TermSpec 0.2 and the local Studio are implemented as the > long-term architecture, but public compatibility is not guaranteed until 1.0. -![TermKit Studio concept workspace](.github/screenshots/studio-concepts.jpg) +![TermKit Studio concept workspace](.github/screenshots/studio-concepts-v2.png) TermKit Studio keeps concept identity, multilingual lexical entries, evidence, relations and revision state visible in one dense workspace. Canonical files do @@ -116,7 +116,13 @@ reconciliation capabilities, not at the center of the product. ### Review semantic changes -![TermKit Studio review workspace](.github/screenshots/studio-review.jpg) +![TermKit Studio draft inbox](.github/screenshots/studio-drafts-v2.png) + +Every edit is autosaved as one atomic ChangeSet. Draft Inbox is the shared +continuation point for inspecting, submitting, rebasing, applying or discarding +work without implying that canonical files have already changed. + +![TermKit Studio review workspace](.github/screenshots/studio-review-v2.png) A draft is an ordered semantic diff rather than an opaque form submission. Language and domain reviewers decide separately, provide rationale, and approve @@ -124,7 +130,7 @@ the same ChangeSet before canonical source is updated. ### Explain runtime decisions -![TermKit Studio runtime lab](.github/screenshots/studio-runtime.jpg) +![TermKit Studio runtime lab](.github/screenshots/studio-runtime-v2.png) Runtime Lab shows source offsets, concept identity, selected pack, target term, policy and evidence. The same bounded result can be consumed through the Python diff --git a/apps/studio/bun.lock b/apps/studio/bun.lock index 452ad80..3151f5e 100644 --- a/apps/studio/bun.lock +++ b/apps/studio/bun.lock @@ -14,6 +14,7 @@ "react-dom": "^19.2.0", }, "devDependencies": { + "@playwright/test": "^1.55.0", "@types/react": "^19.2.2", "@types/react-dom": "^19.2.2", "@vitejs/plugin-react": "^5.0.4", @@ -126,6 +127,8 @@ "@napi-rs/lzma-linux-x64-gnu": ["@napi-rs/lzma-linux-x64-gnu@1.5.1", "", { "os": "linux", "cpu": "x64" }, "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ=="], + "@playwright/test": ["@playwright/test@1.62.1", "", { "dependencies": { "playwright": "1.62.1" }, "bin": { "playwright": "cli.js" } }, "sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ=="], + "@radix-ui/primitive": ["@radix-ui/primitive@1.1.7", "", {}, "sha512-rqWnm76nYT8HoNNqEjpgJ7Pw/DrBj5iBTrmEPo6HTX5+VJyBNOqTdv4g89G63HuR5g0AaENoAcH7Is5fF2kZ8Q=="], "@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.5", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-+48PbAAbq3didjJxa+OaWY2ZwgAKsNiRGyeHKszblZMQ+kcpd9pAaT11cMkGEie0vsOi3QdeTE6d5Fe3Gn61kA=="], @@ -324,6 +327,10 @@ "picomatch": ["picomatch@4.0.5", "", {}, "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A=="], + "playwright": ["playwright@1.62.1", "", { "dependencies": { "playwright-core": "1.62.1" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg=="], + + "playwright-core": ["playwright-core@1.62.1", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw=="], + "postcss": ["postcss@8.5.26", "", { "dependencies": { "nanoid": "^3.3.17", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ=="], "react": ["react@19.2.8", "", {}, "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw=="], @@ -377,5 +384,7 @@ "why-is-node-running": ["why-is-node-running@2.3.0", "", { "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" }, "bin": { "why-is-node-running": "cli.js" } }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="], "yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], + + "playwright/fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="], } } diff --git a/apps/studio/e2e/__screenshots__/concepts-v2.png b/apps/studio/e2e/__screenshots__/concepts-v2.png new file mode 100644 index 0000000..9e4831b Binary files /dev/null and b/apps/studio/e2e/__screenshots__/concepts-v2.png differ diff --git a/apps/studio/e2e/studio.spec.ts b/apps/studio/e2e/studio.spec.ts new file mode 100644 index 0000000..be41941 --- /dev/null +++ b/apps/studio/e2e/studio.spec.ts @@ -0,0 +1,35 @@ +import { expect, test } from "@playwright/test"; + +test("navigates the curation workflow and command palette", async ({ page }) => { + await page.goto("/studio/#/concepts"); + + await expect(page.getByText("Canonical source valid")).toBeVisible(); + await expect(page.getByRole("tab", { name: "Overview" })).toBeVisible(); + await expect(page.getByText("pilot valve", { exact: true }).first()).toBeVisible(); + + await page.getByRole("button", { name: /Drafts/ }).click(); + await expect(page.getByRole("heading", { name: "Draft Inbox" })).toBeVisible(); + + await page.keyboard.press("g"); + await page.keyboard.press("t"); + await expect(page.getByRole("heading", { name: "Runtime Lab" })).toBeVisible(); + + await page.keyboard.press("ControlOrMeta+k"); + const palette = page.getByRole("dialog"); + await palette.getByPlaceholder("Search commands…").fill("pilot"); + await expect(palette.getByText("Concepts", { exact: true })).toBeVisible(); + await expect(palette.getByRole("button", { name: /pilot valve/ })).toBeVisible(); +}); + +test("Concepts 2.0 keeps its visual hierarchy", async ({ page }) => { + await page.goto("/studio/#/concepts"); + await expect(page.getByRole("tab", { name: "Overview" })).toBeVisible(); + + await expect(page).toHaveScreenshot("concepts-v2.png", { + fullPage: true, + mask: [ + page.locator(".draft-shortcut"), + page.getByRole("button", { name: /Drafts/ }), + ], + }); +}); diff --git a/apps/studio/package.json b/apps/studio/package.json index 630d74c..cddafe2 100644 --- a/apps/studio/package.json +++ b/apps/studio/package.json @@ -1,6 +1,6 @@ { "name": "@termkit/studio", - "version": "0.2.0-alpha.0", + "version": "0.2.1-alpha.0", "private": true, "type": "module", "packageManager": "bun@1.3.14", @@ -8,7 +8,8 @@ "dev": "vite", "build": "tsc -b && vite build", "check": "tsc -b --pretty false", - "test": "vitest run" + "test": "vitest run", + "test:e2e": "playwright test" }, "dependencies": { "@radix-ui/react-dialog": "^1.1.15", @@ -20,6 +21,7 @@ "react-dom": "^19.2.0" }, "devDependencies": { + "@playwright/test": "^1.55.0", "@types/react": "^19.2.2", "@types/react-dom": "^19.2.2", "@vitejs/plugin-react": "^5.0.4", diff --git a/apps/studio/playwright.config.ts b/apps/studio/playwright.config.ts new file mode 100644 index 0000000..5e6f021 --- /dev/null +++ b/apps/studio/playwright.config.ts @@ -0,0 +1,28 @@ +import { defineConfig, devices } from "@playwright/test"; + +export default defineConfig({ + testDir: "./e2e", + fullyParallel: false, + forbidOnly: Boolean(process.env.CI), + retries: process.env.CI ? 1 : 0, + reporter: process.env.CI ? "github" : "list", + snapshotPathTemplate: "{testDir}/__screenshots__/{arg}{ext}", + expect: { + toHaveScreenshot: { + animations: "disabled", + maxDiffPixelRatio: 0.04, + }, + }, + use: { + ...devices["Desktop Chrome"], + baseURL: "http://127.0.0.1:8771", + trace: "retain-on-failure", + }, + webServer: { + command: + "../../.venv/bin/termkit studio ../../examples/packs/demo-hydraulics --no-open --port 8771", + url: "http://127.0.0.1:8771/health", + reuseExistingServer: false, + timeout: 30_000, + }, +}); diff --git a/apps/studio/src/App.tsx b/apps/studio/src/App.tsx index 11f0e96..5601000 100644 --- a/apps/studio/src/App.tsx +++ b/apps/studio/src/App.tsx @@ -9,6 +9,7 @@ import { pageFromHash, type PageId } from "./navigation"; import { CandidatesPage } from "./pages/CandidatesPage"; import { ConceptsPage } from "./pages/ConceptsPage"; import { ExchangePage } from "./pages/ExchangePage"; +import { DraftsPage } from "./pages/DraftsPage"; import { RelationsPage } from "./pages/RelationsPage"; import { ReleasesPage } from "./pages/ReleasesPage"; import { ReviewPage } from "./pages/ReviewPage"; @@ -16,6 +17,7 @@ import { RuntimePage } from "./pages/RuntimePage"; import { SourcesPage } from "./pages/SourcesPage"; const pages: Record React.JSX.Element> = { + drafts: DraftsPage, concepts: ConceptsPage, candidates: CandidatesPage, review: ReviewPage, @@ -44,8 +46,34 @@ export function App() { setCommandOpen(true); } }; + let chord = ""; + let chordTimer = 0; + const shortcuts = (event: KeyboardEvent) => { + const element = event.target as HTMLElement | null; + if (element?.matches("input, textarea, select, [contenteditable=true]")) return; + const key = event.key.toLowerCase(); + const map: Record = { + d: "drafts", c: "concepts", a: "candidates", r: "review", + s: "sources", l: "relations", e: "exchange", t: "runtime", p: "releases" + }; + if (chord === "g" && map[key]) { + event.preventDefault(); + window.location.hash = `#/${map[key]}`; + chord = ""; + window.clearTimeout(chordTimer); + } else if (key === "g" && !event.metaKey && !event.ctrlKey && !event.altKey) { + chord = "g"; + chordTimer = window.setTimeout(() => { chord = ""; }, 900); + } else { + chord = ""; + } + }; window.addEventListener("keydown", keyboard); - return () => window.removeEventListener("keydown", keyboard); + window.addEventListener("keydown", shortcuts); + return () => { + window.removeEventListener("keydown", keyboard); + window.removeEventListener("keydown", shortcuts); + }; }, []); const Page = pages[page]; diff --git a/apps/studio/src/api/client.test.ts b/apps/studio/src/api/client.test.ts new file mode 100644 index 0000000..ba5e459 --- /dev/null +++ b/apps/studio/src/api/client.test.ts @@ -0,0 +1,36 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { api } from "./client"; + +afterEach(() => vi.unstubAllGlobals()); + +describe("Studio API workflow contracts", () => { + it("saves the complete operation set in one request", async () => { + const requests: Array<[RequestInfo | URL, RequestInit | undefined]> = []; + const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + requests.push([input, init]); + return new Response(JSON.stringify({ changeset_id: "changeset.test", operations: [], status: "DRAFT" }), { status: 200, headers: { "Content-Type": "application/json" } }); + }); + vi.stubGlobal("fetch", fetchMock); + await api.saveDraft({ author: "curator", reason: "Edit", operations: [ + { kind: "UPDATE_CONCEPT", subject_kind: "CONCEPT", subject_id: "concept.test", after: { notes: ["one"] } }, + { kind: "UPDATE_LEXICAL_ENTRY", subject_kind: "LEXICAL_ENTRY", subject_id: "term.test", after: { text: "term" } } + ] }); + expect(fetchMock).toHaveBeenCalledTimes(1); + const [url, request] = requests[0]!; + expect(url).toBe("/studio/api/v1/drafts"); + expect(request?.method).toBe("PUT"); + expect(JSON.parse(String(request?.body)).operations).toHaveLength(2); + }); + + it("selects the compiled backend explicitly", async () => { + const requests: Array<[RequestInfo | URL, RequestInit | undefined]> = []; + const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + requests.push([input, init]); + return new Response(JSON.stringify({ source_language: "en", target_language: "zh-Hans", matches: [], warnings: [], pack_coordinates: [] }), { status: 200, headers: { "Content-Type": "application/json" } }); + }); + vi.stubGlobal("fetch", fetchMock); + await api.resolve({ text: "pilot valve", source_language: "en", target_language: "zh-Hans" }, "compiled"); + expect(requests[0]?.[0]).toBe("/studio/api/v1/runtime/resolve?backend=compiled"); + }); +}); diff --git a/apps/studio/src/api/client.ts b/apps/studio/src/api/client.ts index a14834c..bb8cea7 100644 --- a/apps/studio/src/api/client.ts +++ b/apps/studio/src/api/client.ts @@ -7,6 +7,7 @@ import type { Relation, ResolutionResult, RuntimeCheckResult, + RuntimeStatus, ReviewDecision, ReviewTask, SourceSummary, @@ -55,6 +56,22 @@ export const api = { request(`/concepts${queryString(filters)}`), concept: (id: string) => request(`/concepts/${encodeURIComponent(id)}`), changesets: (status = "") => request(`/changesets${queryString({ status })}`), + drafts: () => request("/drafts"), + saveDraft: (body: { + changeset_id?: string; + author: string; + reason: string; + operations: Array<{ + kind: string; + subject_kind: string; + subject_id: string; + after: Record | null; + }>; + }) => request("/drafts", { method: "PUT", body: JSON.stringify(body) }), + discardChangeset: (id: string) => + request(`/changesets/${encodeURIComponent(id)}/discard`, { method: "POST" }), + rebaseChangeset: (id: string) => + request(`/changesets/${encodeURIComponent(id)}/rebase`, { method: "POST" }), createChangeset: (body: { author: string; reason: string; source_run_id?: string }) => request("/changesets", { method: "POST", body: JSON.stringify(body) }), addOperation: ( @@ -108,25 +125,30 @@ export const api = { request("/exchange/preview", { method: "POST", body: JSON.stringify(body) }), createExchangeChangeset: (body: ExchangeRequest) => request("/exchange/changeset", { method: "POST", body: JSON.stringify(body) }), + runtimeStatus: () => request("/runtime/status"), resolve: (body: { text: string; source_language: string; target_language: string; domain_hints?: string[]; - }) => request("/runtime/resolve", { method: "POST", body: JSON.stringify(body) }), + }, backend: "source" | "compiled" = "source") => + request(`/runtime/resolve${queryString({ backend })}`, { + method: "POST", + body: JSON.stringify(body) + }), check: (body: { source: string; translation: string; source_language: string; target_language: string; domain_hints?: string[]; - }) => + }, backend: "source" | "compiled" = "source") => request( - "/runtime/check", + `/runtime/check${queryString({ backend })}`, { method: "POST", body: JSON.stringify(body) } ), compileRuntime: () => - request<{ database_path: string; lexical_count: number; matcher_nodes: number }>( + request<{ database_path: string; lexical_count: number; matcher_nodes: number; lock_digest: string; built_at: string }>( "/runtime/compile", { method: "POST" } ), diff --git a/apps/studio/src/api/types.ts b/apps/studio/src/api/types.ts index 4a01e1e..746e4f0 100644 --- a/apps/studio/src/api/types.ts +++ b/apps/studio/src/api/types.ts @@ -150,7 +150,15 @@ export interface ChangeSet { source_run_id: string; operations: ChangeOperation[]; review_ids: string[]; - status: "DRAFT" | "IN_REVIEW" | "APPROVED" | "APPLIED" | "REJECTED" | "REVERTED"; + status: + | "DRAFT" + | "CONFLICTED" + | "IN_REVIEW" + | "APPROVED" + | "APPLIED" + | "REJECTED" + | "REVERTED" + | "DISCARDED"; } export interface ConceptDetail { @@ -175,6 +183,8 @@ export interface WorkspaceSummary { dirty_changesets: number; validation_ok: boolean; validation_errors: number; + recovery_mode: boolean; + validation_issues: Array<{ code: string; message: string; severity: string; record_id: string }>; } export interface CandidateConcept { @@ -290,6 +300,20 @@ export interface RuntimeCheckResult { }; } +export interface RuntimeDescriptor { + backend: "source" | "compiled"; + available: boolean; + pack_coordinates: string[]; + lock_digest: string; + built_at: string; + database_path: string; +} + +export interface RuntimeStatus { + source: RuntimeDescriptor; + compiled: RuntimeDescriptor; +} + export interface ExchangeMapping { column: string; target: diff --git a/apps/studio/src/components/CommandPalette.tsx b/apps/studio/src/components/CommandPalette.tsx index e1d8c4f..2ad343f 100644 --- a/apps/studio/src/components/CommandPalette.tsx +++ b/apps/studio/src/components/CommandPalette.tsx @@ -1,8 +1,10 @@ import { useEffect, useMemo, useState } from "react"; +import { useQuery } from "@tanstack/react-query"; import * as Dialog from "@radix-ui/react-dialog"; import { Command, Search, X } from "lucide-react"; import { navigate, navigation, type PageId } from "../navigation"; +import { api } from "../api/client"; interface CommandPaletteProps { open: boolean; @@ -14,10 +16,23 @@ export function CommandPalette({ open, onOpenChange }: CommandPaletteProps) { useEffect(() => { if (!open) setQuery(""); }, [open]); - const commands = useMemo( - () => navigation.filter((item) => item.label.toLowerCase().includes(query.toLowerCase())), - [query] - ); + const concepts = useQuery({ queryKey: ["command-concepts", query], queryFn: () => api.concepts({ query, limit: 8 }), enabled: open && query.trim().length > 1 }); + const sources = useQuery({ queryKey: ["command-sources"], queryFn: api.sources, enabled: open }); + const drafts = useQuery({ queryKey: ["drafts"], queryFn: api.drafts, enabled: open }); + const reviews = useQuery({ queryKey: ["reviews"], queryFn: () => api.reviews(), enabled: open }); + const commands = useMemo(() => navigation.filter((item) => { + const aliases: Partial> = { + drafts: "changeset autosave continue", + concepts: "term lexical taxonomy", + sources: "register provenance rights", + runtime: "compile resolve check termdb", + releases: "validate build pack changelog" + }; + return `open ${item.label} ${item.shortcut} ${aliases[item.id] ?? ""}`.toLowerCase().includes(query.toLowerCase()); + }), [query]); + const matchingSources = sources.data?.filter((item) => `${item.source.title} ${item.source.source_id}`.toLowerCase().includes(query.toLowerCase())).slice(0, 5) ?? []; + const matchingDrafts = drafts.data?.filter((item) => `${item.reason} ${item.changeset_id} ${item.status}`.toLowerCase().includes(query.toLowerCase())).slice(0, 5) ?? []; + const matchingReviews = reviews.data?.filter((item) => `${item.title} ${item.task_id} ${item.status}`.toLowerCase().includes(query.toLowerCase())).slice(0, 5) ?? []; const select = (page: PageId) => { navigate(page); @@ -54,7 +69,11 @@ export function CommandPalette({ open, onOpenChange }: CommandPaletteProps) { ); })} - {commands.length === 0 &&
No matching command
} + {query.length > 1 && concepts.data?.items.length ? <>
Concepts
{concepts.data.items.map((item) => )} : null} + {query.length > 1 && matchingSources.length ? <>
Sources
{matchingSources.map((item) => )} : null} + {query.length > 1 && matchingDrafts.length ? <>
ChangeSets
{matchingDrafts.map((item) => )} : null} + {query.length > 1 && matchingReviews.length ? <>
Review tasks
{matchingReviews.map((item) => )} : null} + {commands.length === 0 && !concepts.data?.items.length && !matchingSources.length && !matchingDrafts.length && !matchingReviews.length &&
No matching object or command
}