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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,10 @@ uv run termkit resolve \
quiet and keyboard-friendly; machine assistance belongs behind Forge and
reconciliation capabilities, not at the center of the product.

Studio opens in Simplified Chinese by default. Use the language switch in the
top bar to move between 中文 and English; the preference is stored locally in
the browser and never changes canonical terminology data.

### Review semantic changes

![TermKit Studio draft inbox](.github/screenshots/studio-drafts-v2.png)
Expand Down
13 changes: 13 additions & 0 deletions apps/studio/e2e/studio.spec.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { expect, test } from "@playwright/test";

test("navigates the curation workflow and command palette", async ({ page }) => {
await page.addInitScript(() => localStorage.setItem("termkit.studio.locale", "en"));
await page.goto("/studio/#/concepts");

await expect(page.getByText("Canonical source valid")).toBeVisible();
Expand All @@ -22,6 +23,7 @@ test("navigates the curation workflow and command palette", async ({ page }) =>
});

test("Concepts 2.0 keeps its visual hierarchy", async ({ page }) => {
await page.addInitScript(() => localStorage.setItem("termkit.studio.locale", "en"));
await page.goto("/studio/#/concepts");
await expect(page.getByRole("tab", { name: "Overview" })).toBeVisible();

Expand All @@ -33,3 +35,14 @@ test("Concepts 2.0 keeps its visual hierarchy", async ({ page }) => {
],
});
});

test("defaults to Chinese and can switch back to English", async ({ page }) => {
await page.goto("/studio/#/concepts");
await expect(page.getByRole("tab", { name: "概览" })).toBeVisible();
await expect(page.getByText("规范源有效")).toBeVisible();

await page.getByRole("button", { name: "切换为英文" }).click();
await expect(page.getByRole("tab", { name: "Overview" })).toBeVisible();
await page.reload();
await expect(page.getByRole("tab", { name: "Overview" })).toBeVisible();
});
26 changes: 14 additions & 12 deletions apps/studio/src/components/CommandPalette.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,15 @@ import { Command, Search, X } from "lucide-react";

import { navigate, navigation, type PageId } from "../navigation";
import { api } from "../api/client";
import { useLocale } from "../i18n";

interface CommandPaletteProps {
open: boolean;
onOpenChange: (value: boolean) => void;
}

export function CommandPalette({ open, onOpenChange }: CommandPaletteProps) {
const { locale, tr } = useLocale();
const [query, setQuery] = useState("");
useEffect(() => {
if (!open) setQuery("");
Expand All @@ -28,7 +30,7 @@ export function CommandPalette({ open, onOpenChange }: CommandPaletteProps) {
runtime: "compile resolve check termdb",
releases: "validate build pack changelog"
};
return `open ${item.label} ${item.shortcut} ${aliases[item.id] ?? ""}`.toLowerCase().includes(query.toLowerCase());
return `open ${item.label.en} ${item.label.zh} ${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) ?? [];
Expand All @@ -44,39 +46,39 @@ export function CommandPalette({ open, onOpenChange }: CommandPaletteProps) {
<Dialog.Portal>
<Dialog.Overlay className="dialog-overlay" />
<Dialog.Content className="command-dialog" aria-describedby={undefined}>
<Dialog.Title className="sr-only">Command palette</Dialog.Title>
<Dialog.Title className="sr-only">{tr("Command palette", "命令面板")}</Dialog.Title>
<div className="command-search">
<Search size={16} />
<input
autoFocus
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="Search commands…"
placeholder={tr("Search commands…", "搜索命令、概念或来源…")}
/>
<Dialog.Close aria-label="Close command palette">
<Dialog.Close aria-label={tr("Close command palette", "关闭命令面板")}>
<X size={15} />
</Dialog.Close>
</div>
<div className="command-list">
<div className="command-group-label">Open workspace</div>
<div className="command-group-label">{tr("Open workspace", "打开工作区")}</div>
{commands.map((item) => {
const Icon = item.icon;
return (
<button key={item.id} onClick={() => select(item.id)}>
<Icon size={16} />
<span>Open {item.label}</span>
<span>{tr("Open", "打开")} {locale === "zh-CN" ? item.label.zh : item.label.en}</span>
<kbd>{item.shortcut}</kbd>
</button>
);
})}
{query.length > 1 && concepts.data?.items.length ? <><div className="command-group-label">Concepts</div>{concepts.data.items.map((item) => <button key={item.concept_id} onClick={() => { window.location.hash = `#/concepts?concept=${encodeURIComponent(item.concept_id)}`; onOpenChange(false); }}><Search size={15} /><span>{item.display_label}<small>{item.concept_id}</small></span></button>)}</> : null}
{query.length > 1 && matchingSources.length ? <><div className="command-group-label">Sources</div>{matchingSources.map((item) => <button key={item.source.source_id} onClick={() => { navigate("sources"); onOpenChange(false); }}><Search size={15} /><span>{item.source.title}<small>{item.source.source_id}</small></span></button>)}</> : null}
{query.length > 1 && matchingDrafts.length ? <><div className="command-group-label">ChangeSets</div>{matchingDrafts.map((item) => <button key={item.changeset_id} onClick={() => { window.location.hash = `#/drafts?changeset=${encodeURIComponent(item.changeset_id)}`; onOpenChange(false); }}><Search size={15} /><span>{item.reason || "Untitled draft"}<small>{item.status}</small></span></button>)}</> : null}
{query.length > 1 && matchingReviews.length ? <><div className="command-group-label">Review tasks</div>{matchingReviews.map((item) => <button key={item.task_id} onClick={() => { navigate("review"); onOpenChange(false); }}><Search size={15} /><span>{item.title}<small>{item.status}</small></span></button>)}</> : null}
{commands.length === 0 && !concepts.data?.items.length && !matchingSources.length && !matchingDrafts.length && !matchingReviews.length && <div className="command-empty">No matching object or command</div>}
{query.length > 1 && concepts.data?.items.length ? <><div className="command-group-label">{tr("Concepts", "概念")}</div>{concepts.data.items.map((item) => <button key={item.concept_id} onClick={() => { window.location.hash = `#/concepts?concept=${encodeURIComponent(item.concept_id)}`; onOpenChange(false); }}><Search size={15} /><span>{item.display_label}<small>{item.concept_id}</small></span></button>)}</> : null}
{query.length > 1 && matchingSources.length ? <><div className="command-group-label">{tr("Sources", "来源")}</div>{matchingSources.map((item) => <button key={item.source.source_id} onClick={() => { navigate("sources"); onOpenChange(false); }}><Search size={15} /><span>{item.source.title}<small>{item.source.source_id}</small></span></button>)}</> : null}
{query.length > 1 && matchingDrafts.length ? <><div className="command-group-label">{tr("ChangeSets", "变更集")}</div>{matchingDrafts.map((item) => <button key={item.changeset_id} onClick={() => { window.location.hash = `#/drafts?changeset=${encodeURIComponent(item.changeset_id)}`; onOpenChange(false); }}><Search size={15} /><span>{item.reason || tr("Untitled draft", "未命名草稿")}<small>{item.status}</small></span></button>)}</> : null}
{query.length > 1 && matchingReviews.length ? <><div className="command-group-label">{tr("Review tasks", "审核任务")}</div>{matchingReviews.map((item) => <button key={item.task_id} onClick={() => { navigate("review"); onOpenChange(false); }}><Search size={15} /><span>{item.title}<small>{item.status}</small></span></button>)}</> : null}
{commands.length === 0 && !concepts.data?.items.length && !matchingSources.length && !matchingDrafts.length && !matchingReviews.length && <div className="command-empty">{tr("No matching object or command", "没有匹配的对象或命令")}</div>}
</div>
<footer className="command-footer">
<Command size={13} /> Keyboard-first workspace navigation
<Command size={13} /> {tr("Keyboard-first workspace navigation", "键盘优先的工作区导航")}
</footer>
</Dialog.Content>
</Dialog.Portal>
Expand Down
6 changes: 4 additions & 2 deletions apps/studio/src/components/SemanticDiff.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { useState } from "react";
import { ChevronDown, ChevronRight } from "lucide-react";

import type { ChangeOperation } from "../api/types";
import { useLocale } from "../i18n";

function display(value: unknown): string {
if (Array.isArray(value)) return value.join(" · ") || "None";
Expand All @@ -15,15 +16,16 @@ export function SemanticDiff({ operations }: { operations: ChangeOperation[] })
}

function SemanticOperation({ operation }: { operation: ChangeOperation }) {
const { tr } = useLocale();
const [raw, setRaw] = useState(false);
const before = operation.before ?? {};
const after = operation.after ?? {};
const fields = Object.keys(operation.after === null ? before : after)
.filter((field) => JSON.stringify(before[field]) !== JSON.stringify(after[field]));
return <article className="semantic-operation">
<header><div><span>{operation.subject_kind.replaceAll("_", " ")}</span><strong>{operation.kind.replaceAll("_", " ")}</strong></div><code>{operation.subject_id}</code></header>
<div className="field-diff-list">{fields.length ? fields.map((field) => <div className="field-diff" key={field}><span>{field.replaceAll("_", " ")}</span><del>{display(before[field])}</del><ins>{display(after[field])}</ins></div>) : <p>No field-level change.</p>}</div>
<button className="raw-toggle" onClick={() => setRaw(!raw)}>{raw ? <ChevronDown size={13} /> : <ChevronRight size={13} />} View raw JSON</button>
<div className="field-diff-list">{fields.length ? fields.map((field) => <div className="field-diff" key={field}><span>{field.replaceAll("_", " ")}</span><del>{display(before[field])}</del><ins>{display(after[field])}</ins></div>) : <p>{tr("No field-level change.", "没有字段级变更。")}</p>}</div>
<button className="raw-toggle" onClick={() => setRaw(!raw)}>{raw ? <ChevronDown size={13} /> : <ChevronRight size={13} />} {tr("View raw JSON", "查看原始 JSON")}</button>
{raw && <div className="raw-diff"><pre>{JSON.stringify(operation.before, null, 2)}</pre><pre>{JSON.stringify(operation.after, null, 2)}</pre></div>}
</article>;
}
32 changes: 21 additions & 11 deletions apps/studio/src/components/Shell.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import type { ReactNode } from "react";
import { Command, Search } from "lucide-react";
import { Command, Languages, Search } from "lucide-react";

import type { WorkspaceSummary } from "../api/types";
import { navigate, navigation, productIcon, type PageId } from "../navigation";
import { useLocale } from "../i18n";
import { StatusIndicator } from "./ui";

interface ShellProps {
Expand All @@ -13,9 +14,15 @@ interface ShellProps {
}

export function Shell({ activePage, workspace, onOpenCommand, children }: ShellProps) {
const { locale, setLocale, tr } = useLocale();
const ProductIcon = productIcon;
const coordinate = workspace?.coordinate ?? "Opening workspace";
const coordinate = workspace?.coordinate ?? tr("Opening workspace", "正在打开工作区");
const groups = ["CURATE", "KNOWLEDGE", "DELIVER"] as const;
const groupLabels = {
CURATE: tr("CURATE", "编审"),
KNOWLEDGE: tr("KNOWLEDGE", "知识"),
DELIVER: tr("DELIVER", "交付"),
};

return (
<div className="studio-shell">
Expand All @@ -30,46 +37,49 @@ export function Shell({ activePage, workspace, onOpenCommand, children }: ShellP
<span className="mono">main</span>
<button className="draft-shortcut" onClick={() => navigate("drafts")}>
<StatusIndicator status={workspace?.dirty_changesets ? "DRAFT" : "APPROVED"} compact />
{workspace?.dirty_changesets ? `${workspace.dirty_changesets} active` : "clean"}
{workspace?.dirty_changesets ? tr(`${workspace.dirty_changesets} active`, `${workspace.dirty_changesets} 个进行中`) : tr("clean", "无草稿")}
</button>
</div>
<button className="locale-toggle" onClick={() => setLocale(locale === "zh-CN" ? "en" : "zh-CN")} aria-label={tr("Switch to Chinese", "切换为英文")}>
<Languages size={14} /><span>{locale === "zh-CN" ? "English" : "中文"}</span>
</button>
<button className="command-trigger" onClick={onOpenCommand}>
<Search size={14} />
<span>Search or run command</span>
<span>{tr("Search or run command", "搜索或运行命令")}</span>
<kbd>
<Command size={11} /> K
</kbd>
</button>
</header>

<aside className="sidebar" aria-label="Studio navigation">
<aside className="sidebar" aria-label={tr("Studio navigation", "工作台导航")}>
<nav>
{groups.map((group) => <div className="nav-group" key={group}>
<span className="nav-group-label">{group}</span>
<span className="nav-group-label">{groupLabels[group]}</span>
{navigation.filter((item) => item.group === group).map((item) => {
const Icon = item.icon;
return <button key={item.id} className={item.id === activePage ? "active" : ""} onClick={() => navigate(item.id)}>
<Icon size={16} strokeWidth={1.8} /><span>{item.label}</span>
<Icon size={16} strokeWidth={1.8} /><span>{locale === "zh-CN" ? item.label.zh : item.label.en}</span>
{item.id === "drafts" && workspace?.dirty_changesets ? <strong>{workspace.dirty_changesets}</strong> : <kbd>{item.shortcut}</kbd>}
</button>;
})}
</div>)}
</nav>
<div className="sidebar-meta">
<span className={`health-dot ${workspace?.validation_ok ? "ok" : "error"}`} />
<span>{workspace?.recovery_mode ? "Recovery mode" : workspace?.validation_ok ? "Canonical source valid" : "Validation required"}</span>
<span>{workspace?.recovery_mode ? tr("Recovery mode", "恢复模式") : workspace?.validation_ok ? tr("Canonical source valid", "规范源有效") : tr("Validation required", "需要验证")}</span>
</div>
</aside>

<main className="workspace-area">{children}</main>

<footer className="statusbar">
<span>{workspace?.concept_count.toLocaleString() ?? "—"} concepts</span>
<span>{workspace?.concept_count.toLocaleString() ?? "—"} {tr("concepts", "个概念")}</span>
<span>{workspace?.languages.join(" · ") ?? "—"}</span>
<span>{workspace?.evidence_count.toLocaleString() ?? "—"} evidence records</span>
<span>{workspace?.evidence_count.toLocaleString() ?? "—"} {tr("evidence records", "条证据")}</span>
<span className="statusbar-spacer" />
<span className="mono" title={workspace?.source_revision}>
{workspace?.source_revision.slice(0, 10) ?? "no revision"}
{workspace?.source_revision.slice(0, 10) ?? tr("no revision", "无修订")}
</span>
</footer>
</div>
Expand Down
21 changes: 14 additions & 7 deletions apps/studio/src/components/ui.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,14 @@ import {
AlertCircle, Archive, Ban, BookOpen, Check, CheckCircle2, Clock3,
GitBranch, GitMerge, LoaderCircle, LockKeyhole, Pencil, Shield, Star, X
} from "lucide-react";
import { localizedPolicy, localizedRights, localizedStatus, useLocale } from "../i18n";

export function Badge({ children, tone = "neutral" }: { children: ReactNode; tone?: string }) {
return <span className={`badge badge--${tone.toLowerCase()}`}>{children}</span>;
}

export function StatusBadge({ status }: { status: string }) {
const { locale } = useLocale();
const tone =
status === "APPROVED" || status === "STANDARDIZED" || status === "APPLIED"
? "approved"
Expand All @@ -19,10 +21,11 @@ export function StatusBadge({ status }: { status: string }) {
: status === "IN_REVIEW" || status === "AWAITING_REVIEW" || status === "OPEN"
? "review"
: "proposal";
return <Badge tone={tone}>{status.replaceAll("_", " ")}</Badge>;
return <Badge tone={tone}>{localizedStatus(status, locale)}</Badge>;
}

export function StatusIndicator({ status, compact = false }: { status: string; compact?: boolean }) {
const { locale } = useLocale();
const normalized = status.toUpperCase();
const approved = ["APPROVED", "STANDARDIZED", "APPLIED", "VALID", "APPROVE"].includes(normalized);
const rejected = ["REJECTED", "FAILED", "BLOCKED", "REJECT"].includes(normalized);
Expand All @@ -31,37 +34,41 @@ export function StatusIndicator({ status, compact = false }: { status: string; c
const deprecated = ["DEPRECATED", "REVERTED", "DISCARDED"].includes(normalized);
const Icon = conflict ? GitMerge : approved ? Check : rejected ? X : review ? Clock3 : deprecated ? Archive : Pencil;
const tone = conflict ? "conflict" : approved ? "approved" : rejected ? "rejected" : review ? "review" : deprecated ? "deprecated" : "proposal";
return <span className={`status-indicator status-indicator--${tone}`} title={normalized.replaceAll("_", " ")}>
<Icon size={12} />{!compact && <span>{normalized.replaceAll("_", " ")}</span>}
return <span className={`status-indicator status-indicator--${tone}`} title={localizedStatus(normalized, locale)}>
<Icon size={12} />{!compact && <span>{localizedStatus(normalized, locale)}</span>}
</span>;
}

export function PolicyTag({ policy }: { policy: string }) {
const { locale } = useLocale();
const icons = { KEEP: LockKeyhole, MUST: Shield, PREFER: Star, ALLOW: Check, AVOID: Ban } as const;
const Icon = icons[policy as keyof typeof icons] ?? Check;
return <span className={`policy-tag policy-tag--${policy.toLowerCase()}`}><Icon size={12} />{policy}</span>;
return <span className={`policy-tag policy-tag--${policy.toLowerCase()}`}><Icon size={12} />{localizedPolicy(policy, locale)}</span>;
}

export function RightsIndicator({ rights }: { rights: string }) {
const { locale } = useLocale();
const icons = { REDISTRIBUTABLE: Shield, DERIVATION_ALLOWED: GitBranch, REFERENCE_ONLY: BookOpen, RESTRICTED: LockKeyhole } as const;
const Icon = icons[rights as keyof typeof icons] ?? BookOpen;
return <span className={`rights-indicator rights-indicator--${rights.toLowerCase()}`}><Icon size={12} />{rights.replaceAll("_", " ")}</span>;
return <span className={`rights-indicator rights-indicator--${rights.toLowerCase()}`}><Icon size={12} />{localizedRights(rights, locale)}</span>;
}

export function LoadingState({ label = "Loading" }: { label?: string }) {
const { locale } = useLocale();
return (
<div className="state-message">
<LoaderCircle className="spin" size={16} />
<span>{label}</span>
<span>{label === "Loading" && locale === "zh-CN" ? "加载中" : label}</span>
</div>
);
}

export function ErrorState({ error }: { error: unknown }) {
const { tr } = useLocale();
return (
<div className="state-message state-message--error">
<AlertCircle size={16} />
<span>{error instanceof Error ? error.message : "The operation failed"}</span>
<span>{error instanceof Error ? error.message : tr("The operation failed", "操作失败")}</span>
</div>
);
}
Expand Down
Loading
Loading