Merge visual review into full shell and preserve bilingual layout

This commit is contained in:
Codex
2026-09-13 14:55:58 +02:00
69 changed files with 1419 additions and 346 deletions
+337
View File
@@ -0,0 +1,337 @@
import { expect, test, type Page } from "@playwright/test";
import { canonicalWorkspaceFixture, workspaceRevisionFixture, workspaceSummaryFixture } from "../src/test/workspace-fixtures";
// These tests never forward API requests to an installation or invoke a real model.
const workspace = "visual-fixture";
const title = "Clinical research workspace";
const model = { provider: "test", id: "review-model", name: "Review model", reasoning: true };
const revision = workspaceRevisionFixture(workspace);
const evidence = {
id: "evidence:cohort", title: "Cohort definition", kind: "domain", language: "en",
purposes: ["sql_generation"], applies_to: { concepts: ["cohort"], tables: [], columns: [] },
payload: { rule: "# Approved cohort\n\nInclude eligible patients. Review the inclusion criteria before generating SQL." },
provenance: { source_file: "research-guide.md", supporting_excerpts: ["Eligibility is evaluated at admission."] },
review_items: [], file: "curated/domain/cohort.md", status: "active", revision: "1",
};
const evidencePage = {
items: [evidence], total: 1, page: 1, page_size: 25, errors: [], initialized: true,
active_revision: "1", pending_revision: null, source_reviews: [],
location: { repository: "/installation/workspaces", workspace: "/installation/workspaces/clinical-research",
runtime_workspace: "/data/workspaces/clinical-research", host: "installation", command: "tht workspace consolidate",
git_commands: ["git status", "git diff"] },
};
async function fixtures(page: Page, mode: "full" | "embedded" = "full") {
const writes: string[] = [];
await page.route("**/config.js", route => route.fulfill({
contentType: "application/javascript",
body: `window.__THOTHII_CONFIG__ = ${JSON.stringify({ backendBaseUrl: "/api", shell: { mode, defaultLocale: "en" } })};
${mode === "embedded" ? `
document.documentElement.setAttribute("data-bs-theme", "light");
const language = document.createElement("select");
language.className = "omics-language-select";
language.dataset.lang = "en";
language.hidden = true;
document.body.prepend(language);
` : ""}`,
}));
await page.route("http://127.0.0.1:5183/api/**", async route => {
const path = new URL(route.request().url()).pathname.replace(/^\/api/, "");
if (route.request().method() !== "GET") {
writes.push(path);
return route.fulfill({ status: 503, json: { message: "Visual fixture: no writes" } });
}
let body: unknown;
if (path === "/auth/config") body = { mode: "mock", localLogin: false, oidcLogin: false };
else if (path === "/me") body = { issuer: "test", subject: "admin", displayName: "Review administrator", isAdmin: true,
roles: ["admin"], permissions: ["session.use", "session.read_all", "session.manage_all", "database.manage", "workspace.manage", "workspace.secrets.manage", "memory.manage", "evidence.manage", "pi.manage"], csrfToken: null, session: null };
else if (path === "/settings") body = { workspace, provider: model.provider, model: model.id, thinking: "high" };
else if (path === "/models") body = { models: [model] };
else if (path === "/workspaces") body = [workspaceSummaryFixture(workspace, { displayName: title })];
else if (path === `/workspaces/${workspace}`) body = { workspace: canonicalWorkspaceFixture(workspace), revision };
else if (path.endsWith("/runtime-configuration")) body = { workspaceId: workspace, revision, configurationState: "ready", requirements: [] };
else if (path.endsWith("/preprocessing")) body = { schemaVersion: 1, workspaceId: workspace, state: "ready", actionable: true, clearable: true,
detail: "Catalog revision 1 is indexed.", metadataRevision: 1, preprocessedMetadataRevision: 1 };
else if (path === "/workspace-registry/status") body = { branch: "main", ahead: 0, behind: 0, degraded: false };
else if (path.endsWith("/memory/pending")) body = [];
else if (path.endsWith("/memory")) body = { items: [], total: 0, page: 1, page_size: 25 };
else if (path.endsWith("/evidence")) body = evidencePage;
else if (path.includes("/evidence/")) body = { ...evidencePage, item: evidence };
else if (path === "/pi-management/status") body = { ready: true, credentials: "present", hostPlatform: "macos", version: "0.80.3",
config: { provider: model.provider, model: model.id, reasoning: "high" } };
else if (path === "/sessions") body = [{ id: "archived-fixture", status: "finalized", question: "How many eligible patients?", name: "Cohort review",
archived: true, active: false, workspace, created_at: "2026-09-01T12:00:00Z", updated_at: null, author: null, group: null, summary: null }];
else if (path.endsWith("/documents")) body = [{ key: "question", title: "Original question", phase: "F1", format: "markdown", content: "# Cohort review\n\nHow many eligible patients were admitted in 2025?" }];
else if (path === "/health/dwh") body = { ok: true, detail: "Available" };
else if (path === "/catalog/databases") body = [];
else if (path === "/catalog/metrics") body = { scope: "global", databaseId: null, tables: 0, columns: 0, sensitiveColumns: 0, relationships: 0,
descriptionTargets: 0, describedTargets: 0, descriptionCoverage: 0, updatedAt: null };
else if (path === "/catalog/metadata-generation/models") body = { models: [], default: null };
else if (path.endsWith("-runs")) body = [];
else return route.fulfill({ status: 404, json: { message: `Missing visual fixture: ${path}` } });
return route.fulfill({ json: body });
});
return writes;
}
async function navigation(page: Page) {
const trigger = page.getByRole("button", { name: "Navigation", exact: true });
if (await trigger.isVisible()) await trigger.click();
}
async function admin(page: Page, name: string) {
await navigation(page);
const toggle = page.getByRole("button", { name: "Administration", exact: true });
if (await toggle.getAttribute("aria-expanded") !== "true") await toggle.click();
await page.getByRole("button", { name, exact: true }).click();
}
async function inspect(page: Page) {
return page.evaluate(() => {
const visible = (e: Element) => {
const box = e.getBoundingClientRect();
return box.width > 0 && box.height > 0 && getComputedStyle(e).visibility !== "hidden" && !e.closest("[hidden]");
};
return {
overflow: document.documentElement.scrollWidth - innerWidth,
serif: [...document.querySelectorAll("h1,h2,h3,h4,button,label")].filter(visible)
.filter(e => /Fraunces|Georgia|Times|ui-serif/.test(getComputedStyle(e).fontFamily)).map(e => e.textContent?.slice(0,60)),
tiny: [...document.querySelectorAll("h1,h2,h3,h4,button,label,small")].filter(visible)
.filter(e => parseFloat(getComputedStyle(e).fontSize) < 12).map(e => e.textContent?.slice(0,60)),
};
});
}
for (const width of [390, 1280]) {
test(`full shell keeps the visual revision across language and theme changes at ${width}px`, async ({ page }) => {
await page.setViewportSize({ width, height: 1000 });
const writes = await fixtures(page);
await page.goto("/");
const header = page.locator(".thot-full-header");
await expect(header).toBeVisible();
await page.evaluate(() => document.fonts.ready);
expect(await page.evaluate(() => [...document.fonts].some(font =>
font.family.includes("Manrope Variable") && font.status === "loaded",
))).toBe(true);
const layout = await page.locator(".thot-context-layout").boundingBox();
expect(layout!.x).toBeGreaterThanOrEqual(20);
expect(Math.abs(layout!.x - (width - layout!.x - layout!.width))).toBeLessThan(1);
await admin(page, "Database");
await expect(page.getByRole("heading", { name: "Database management", exact: true })).toBeVisible();
await header.getByRole("combobox").selectOption("it");
await expect(page.getByRole("heading", { name: "Gestione database", exact: true })).toBeVisible();
await expect(page.locator(".thot-administration-eyebrow")).toHaveText("Amministrazione");
await expect(page.locator(".thot-context-summary small")).toHaveText("Contesto di lavoro");
await page.getByRole("button", { name: "Usa il tema scuro", exact: true }).click();
await expect(page.locator(".thot-host")).toHaveAttribute("data-theme", "dark");
expect(await inspect(page)).toEqual({ overflow: 0, serif: [], tiny: [] });
await page.reload();
await expect(page.getByRole("heading", { name: "Gestione database", exact: true })).toBeVisible();
await expect(header.getByRole("combobox")).toHaveValue("it");
await expect(page.locator(".thot-host")).toHaveAttribute("data-theme", "dark");
await page.getByRole("button", { name: "Usa il tema chiaro", exact: true }).click();
await header.getByRole("combobox").selectOption("en");
await expect(page.getByRole("heading", { name: "Database management", exact: true })).toHaveCSS("font-size", "24px");
expect(await inspect(page)).toEqual({ overflow: 0, serif: [], tiny: [] });
expect(writes).toEqual([]);
});
}
for (const route of ["/", "/?thoth_route=administration%2Fdatabase"]) {
test(`Core prompt remains readable after loading ${route}`, async ({ page }, testInfo) => {
const writes = await fixtures(page);
await page.goto(route);
if (route !== "/") {
await navigation(page);
await page.getByRole("button", { name: "Return to session", exact: true }).click();
}
const prompt = page.getByRole("textbox", { name: "New question", exact: true });
await expect(prompt).toBeVisible();
await prompt.focus();
const readable = async () => {
await expect.poll(() => prompt.evaluate(el => {
const style = getComputedStyle(el);
return el.clientHeight >= parseFloat(style.lineHeight) + parseFloat(style.paddingTop) + parseFloat(style.paddingBottom);
})).toBe(true);
};
await readable();
await expect(prompt).toHaveCSS("outline-style", "none");
await page.screenshot({ path: testInfo.outputPath("Prompt-focused.png") });
// Full-mode gutters can wrap the empty placeholder on a narrow viewport.
// Compare clearing with that same viewport's initial height, not a single-line constant.
await page.setViewportSize({ width: 390, height: 900 });
await readable();
const emptyHeight = await prompt.evaluate(el => el.clientHeight);
await prompt.fill("First line");
await prompt.press("Shift+Enter");
await prompt.press("a");
await expect(prompt).toHaveValue("First line\na");
await expect.poll(() => prompt.evaluate(el => el.clientHeight)).toBeGreaterThan(40);
const question = "Compare eligible patients across the available cohorts. ".repeat(30);
await prompt.fill(question);
await page.setViewportSize({ width: 390, height: 900 });
await readable();
await expect.poll(() => prompt.evaluate(el => el.clientHeight)).toBe(160);
expect(await prompt.evaluate(el => el.scrollHeight > el.clientHeight)).toBe(true);
await admin(page, "Database");
await navigation(page);
await page.getByRole("button", { name: "Return to session", exact: true }).click();
await expect(prompt).toHaveValue(question);
await readable();
await page.screenshot({ path: testInfo.outputPath("Prompt-multiline-mobile.png") });
await prompt.fill("");
await readable();
await expect.poll(() => prompt.evaluate(el => el.clientHeight)).toBe(emptyHeight);
expect(writes).toEqual([]);
});
}
for (const width of [390, 649, 768, 1280, 1600]) {
test(`all surfaces retain shared typography at ${width}px`, async ({ page }, testInfo) => {
await page.setViewportSize({ width, height: 1000 });
const writes = await fixtures(page);
await page.goto("/");
await expect(page.getByTestId("app-shell")).toBeVisible();
await expect(page.getByRole("button", { name: /Working context/ })).toContainText(title);
for (const name of ["Workspace", "Database", "Memory", "Evidence", "Pi configuration"]) {
await admin(page, name);
const heading = page.getByRole("heading", { name: `${name === "Pi configuration" ? "Pi" : name} management`, exact: true });
await expect(heading).toBeVisible();
await expect(heading).toHaveCSS("font-size", "24px");
expect(await inspect(page)).toEqual({ overflow: 0, serif: [], tiny: [] });
if (name === "Memory") await expect(page.getByText("No memory cards yet.", { exact: false })).toBeVisible();
if (name === "Database") {
const statuses = page.locator(".thot-fleet-ledger__header-status .thot-fleet-ledger__status-item");
await expect(statuses).toHaveCount(3);
const catalog = await statuses.nth(0).boundingBox();
const operations = await statuses.nth(1).boundingBox();
const metadata = await statuses.nth(2).boundingBox();
const summary = await page.locator(".thot-fleet-ledger__kpi-section > h2").boundingBox();
const summaryBlock = await page.locator(".thot-fleet-ledger__kpi-section").boundingBox();
const navigationBlock = await page.locator(".thot-fleet-ledger__navigation").boundingBox();
const gridBlock = await page.locator(".thot-fleet-ledger__content").boundingBox();
const databasesLabel = await page.locator(".thot-fleet-ledger__navigation").getByText("Databases", { exact: true }).boundingBox();
const tablesLabel = await page.locator(".thot-fleet-ledger__kpi-label").getByText("Tables", { exact: true }).boundingBox();
expect(Math.abs(summaryBlock!.x - gridBlock!.x)).toBeLessThan(1);
expect(Math.abs(navigationBlock!.x - gridBlock!.x)).toBeLessThan(1);
expect(Math.abs(summary!.x - databasesLabel!.x)).toBeLessThan(1);
expect(Math.abs(summary!.x - tablesLabel!.x)).toBeLessThan(1);
const gridLabel = await page.locator(".thot-fleet-grid-toolbar").getByText("Workspace databases", { exact: true }).boundingBox();
expect(Math.abs(summary!.x - gridLabel!.x)).toBeLessThan(1);
expect(Math.abs(summaryBlock!.width - gridBlock!.width)).toBeLessThan(1);
expect(Math.abs(navigationBlock!.width - gridBlock!.width)).toBeLessThan(1);
const contextTrigger = page.getByRole("button", { name: /Working context/ });
await contextTrigger.click();
const contextBody = page.locator(".thot-context-body");
await expect(contextBody).toBeVisible();
await expect(contextBody).not.toContainText("Your last choices take precedence");
await expect(contextBody).not.toContainText("Installation default");
await expect(contextBody).not.toContainText("Last choice");
const contextBlock = await contextBody.boundingBox();
const workspaceSelect = await contextBody.getByRole("combobox", { name: "Workspace", exact: true }).boundingBox();
expect(Math.abs(contextBlock!.x - summaryBlock!.x)).toBeLessThan(1);
expect(Math.abs(workspaceSelect!.x - summary!.x)).toBeLessThan(1);
expect(await inspect(page)).toEqual({ overflow: 0, serif: [], tiny: [] });
await page.screenshot({ path: testInfo.outputPath("Context-expanded.png") });
await contextBody.getByRole("button", { name: "Done", exact: true }).click();
expect(Math.abs(catalog!.x - summary!.x)).toBeLessThan(1);
const workArea = await page.locator(".thot-fleet-ledger__workspace").boundingBox();
if (workArea!.width >= 768) {
const coverage = await page.locator(".thot-fleet-ledger__kpi-label").filter({ hasText: "Description coverage" }).boundingBox();
expect(Math.abs(metadata!.x - coverage!.x)).toBeLessThan(1);
expect(Math.abs(operations!.x - (catalog!.x + metadata!.x) / 2)).toBeLessThan(1);
}
for (const status of await statuses.all()) {
const label = await status.locator(".thot-fleet-ledger__status-label > span:first-child").boundingBox();
const mark = await status.locator(".thot-fleet-ledger__status-mark").boundingBox();
const value = await status.locator(".thot-fleet-ledger__status-value").boundingBox();
expect(mark!.x - (label!.x + label!.width)).toBeGreaterThanOrEqual(7);
expect(Math.abs(label!.x - value!.x)).toBeLessThan(1);
}
}
if (name === "Evidence") {
const search = await page.getByRole("textbox", { name: "Search Evidence" }).boundingBox();
expect(search!.width).toBeGreaterThan(180);
}
await page.screenshot({ path: testInfo.outputPath(`${name.replaceAll(" ", "-")}.png`) });
}
await page.getByRole("button", { name: "Host maintenance", exact: true }).click();
await expect(page.getByRole("tab", { name: "macOS", exact: true })).toHaveAttribute("aria-selected", "true");
await expect(page.getByRole("region", { name: "Pi operator workflow" })).toHaveCSS("font-size", "16px");
await page.screenshot({ path: testInfo.outputPath("Pi-guide.png") });
await navigation(page);
await page.getByRole("tab", { name: "All sessions", exact: true }).click();
await expect(page.locator(".thot-wordmark--sidebar")).toHaveCSS("font-size", "32px");
await page.getByRole("button", { name: /Archive \(1\)/ }).click();
await page.getByRole("button", { name: "Cohort review", exact: true }).click();
const documents = page.getByRole("complementary", { name: "Session summary" });
await expect(documents).toBeVisible();
await expect(documents.getByRole("heading", { name: "Original question" })).toBeVisible();
const bounds = await documents.boundingBox();
expect(bounds!.width).toBeGreaterThanOrEqual(Math.min(width, 300));
expect(await inspect(page)).toEqual({ overflow: 0, serif: [], tiny: [] });
await page.screenshot({ path: testInfo.outputPath("Sessions.png") });
await page.getByRole("button", { name: "Close panel", exact: true }).click();
await expect(page.getByRole("textbox", { name: "New question", exact: true })).toBeVisible();
await expect(page.locator(".thot-wordmark--core")).toHaveCSS("font-size", "48px");
await page.screenshot({ path: testInfo.outputPath("Core.png") });
expect(writes).toEqual([]);
});
}
test("theme follows the app, not an unrelated OS preference; keyboard tabs and dirty guard remain", async ({ page }, testInfo) => {
await page.setViewportSize({ width: 1280, height: 900 });
const writes = await fixtures(page);
await page.emulateMedia({ colorScheme: "dark" });
await page.goto("/");
await expect(page.getByTestId("app-shell")).toBeVisible();
await expect(page.getByRole("banner", { name: "Application header" })).toBeVisible();
await admin(page, "Pi configuration");
const testButton = page.getByRole("button", { name: "Test catalog default", exact: true });
await expect(testButton).toHaveCSS("background-color", "oklch(0.9985 0.0006 17.2)");
await page.getByRole("button", { name: "Use dark theme", exact: true }).click();
await expect(page.locator(".thot-context-trigger")).toHaveCSS("color", "oklch(0.931 0 90)");
await expect(testButton).toHaveCSS("background-color", "oklch(0.62 0.008 23.2 / 0.3)");
await page.screenshot({ path: testInfo.outputPath("Pi-dark.png") });
const guideButton = page.getByRole("button", { name: "Host maintenance", exact: true });
await guideButton.click();
const mac = page.getByRole("tab", { name: "macOS", exact: true });
await mac.focus(); await page.keyboard.press("ArrowRight");
await expect(page.getByRole("tab", { name: "Windows", exact: true })).toBeFocused();
await expect(page.getByRole("tab", { name: "Windows", exact: true })).toHaveAttribute("aria-selected", "true");
await admin(page, "Memory");
await page.getByRole("button", { name: "New card", exact: true }).click();
await page.getByRole("textbox", { name: "Title", exact: true }).fill("Keep this draft");
await page.getByRole("button", { name: "Return to session", exact: true }).click();
await expect(page.getByRole("textbox", { name: "Title", exact: true })).toHaveValue("Keep this draft");
await page.screenshot({ path: testInfo.outputPath("Memory-dark-unsaved.png") });
await page.getByRole("button", { name: "Cancel", exact: true }).click();
await page.getByRole("button", { name: "Return to session", exact: true }).click();
await expect(page.getByRole("textbox", { name: "New question", exact: true })).toBeVisible();
expect(writes).toEqual([]);
});
test("host header and left rail leave the application a bounded working area", async ({ page }, testInfo) => {
await page.setViewportSize({ width: 1280, height: 1000 });
await fixtures(page, "embedded");
await page.goto("/");
await expect(page.getByTestId("app-shell")).toBeVisible();
await expect(page.locator(".thot-full-header")).toHaveCount(0);
// Geometry fixture, not a replacement for acceptance inside the real Omics deployment.
await page.addStyleTag({ content: `
body { margin: 0; }
#root { margin-left: 240px; margin-top: 72px; --thoth-app-height: calc(100dvh - 72px); }
.px-4 { padding-left: 2.25rem !important; padding-right: 2.25rem !important; }
` });
await admin(page, "Workspace");
const shell = await page.getByTestId("app-shell").boundingBox();
expect(shell!.x).toBe(240);
expect(shell!.x + shell!.width).toBeLessThanOrEqual(1280);
expect(shell!.y + shell!.height).toBeLessThanOrEqual(1000);
expect(await inspect(page)).toEqual({ overflow: 0, serif: [], tiny: [] });
await page.screenshot({ path: testInfo.outputPath("Host-bounded-Workspace.png") });
await admin(page, "Evidence");
const search = await page.getByRole("textbox", { name: "Search Evidence" }).boundingBox();
expect(search!.width).toBeGreaterThan(180);
expect(await inspect(page)).toEqual({ overflow: 0, serif: [], tiny: [] });
});
-6
View File
@@ -4,12 +4,6 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>ThothII</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&family=Fraunces:opsz,wght@9..144,400;9..144,500;9..144,600;9..144,700&display=swap"
rel="stylesheet"
/>
</head>
<body>
<div id="root"></div>
+10
View File
@@ -9,6 +9,7 @@
"@ag-grid-community/locale": "36.0.0",
"@base-ui/react": "^1.6.0",
"@fontsource-variable/geist": "^5.2.9",
"@fontsource-variable/manrope": "5.3.0",
"@tanstack/react-query": "^5.59.0",
"ag-grid-community": "^36.0.0",
"ag-grid-react": "^36.0.0",
@@ -1491,6 +1492,15 @@
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@fontsource-variable/manrope": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/@fontsource-variable/manrope/-/manrope-5.3.0.tgz",
"integrity": "sha512-6D5dgokHsWDDMtmXHznKa0hK229NN+1a4BLPmUCLqcO1Pw5EEhWY5RFt0AcXnVRAljFFPfRtLkJePQj6LSsV6g==",
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@hono/node-server": {
"version": "1.19.17",
"resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.17.tgz",
+1
View File
@@ -21,6 +21,7 @@
"@ag-grid-community/locale": "36.0.0",
"@base-ui/react": "^1.6.0",
"@fontsource-variable/geist": "^5.2.9",
"@fontsource-variable/manrope": "5.3.0",
"@tanstack/react-query": "^5.59.0",
"ag-grid-community": "^36.0.0",
"ag-grid-react": "^36.0.0",
+16
View File
@@ -0,0 +1,16 @@
import { defineConfig, devices } from "@playwright/test";
export default defineConfig({
testDir: "./e2e",
testMatch: "ui-visual-review.spec.ts",
timeout: 45_000,
workers: 2,
outputDir: "test-results/visual-review",
use: { ...devices["Desktop Chrome"], baseURL: "http://127.0.0.1:5183", trace: "retain-on-failure" },
webServer: {
command: "npm run dev -- --host 127.0.0.1 --port 5183 --strictPort",
url: "http://127.0.0.1:5183",
reuseExistingServer: !process.env.CI,
env: { VITE_BACKEND_URL: "/api" },
},
});
+3 -2
View File
@@ -89,8 +89,9 @@ function DefaultFallback({
{error.message && (
<details className="min-w-0">
<summary className="cursor-pointer text-xs text-muted-foreground hover:text-foreground">
{translate("Details")}</summary>
<pre className="mt-1.5 max-w-full overflow-x-auto rounded-md bg-muted px-2.5 py-1.5 font-mono text-[0.7rem] text-muted-foreground">
{translate("Details")}
</summary>
<pre className="mt-1.5 max-w-full overflow-x-auto rounded-md bg-muted px-2.5 py-1.5 font-mono text-xs text-muted-foreground">
{error.message}
</pre>
</details>
+2 -2
View File
@@ -25,8 +25,8 @@ const buttonVariants = cva(
default:
"h-8 gap-1.5 px-3.5 has-data-[icon=inline-end]:pr-2.5 has-data-[icon=inline-start]:pl-2.5",
xs: "h-6 gap-1 px-2.5 text-xs has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-7 gap-1 px-3 text-[0.8rem] has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2 [&_svg:not([class*='size-'])]:size-3.5",
lg: "h-10 gap-1.5 px-5 text-[0.9rem] has-data-[icon=inline-end]:pr-3.5 has-data-[icon=inline-start]:pl-3.5",
sm: "h-8 gap-1.5 px-3 text-sm has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2 [&_svg:not([class*='size-'])]:size-3.5",
lg: "h-10 gap-1.5 px-5 text-sm has-data-[icon=inline-end]:pr-3.5 has-data-[icon=inline-start]:pl-3.5",
icon: "size-8",
"icon-xs": "size-6 [&_svg:not([class*='size-'])]:size-3",
"icon-sm": "size-7",
+13
View File
@@ -1,5 +1,18 @@
// English gettext message IDs. Workspace content, SQL and identifiers are never translated.
export const itAdmin: Record<string, string> = {
"Import sources, then review proposed changes before activation.": "Importa le fonti, poi rivedi le modifiche proposte prima dell'attivazione.",
"Which sources are imported?": "Quali fonti vengono importate?",
"Place new Markdown drafts in": "Inserisci le nuove bozze Markdown in",
". Import also checks original local documents and configured remote sources. Review changes before they enter recall.": ". L'importazione controlla anche i documenti locali originali e le fonti remote configurate. Rivedi le modifiche prima che diventino disponibili per il recupero delle conoscenze.",
"No memory cards yet. Create a card to add reusable knowledge.": "Non ci sono ancora schede di memoria. Crea una scheda per aggiungere conoscenze riutilizzabili.",
"Run these commands on the installation host. Choose its operating system.": "Esegui questi comandi sul computer dell'installazione. Scegli il suo sistema operativo.",
"Used for Core and Administration when no model has been chosen. The test below checks this default, not your current selection.": "Usato per Core e Amministrazione quando non è stato scelto un modello. Il test qui sotto verifica questo modello predefinito, non la selezione corrente.",
"Change the installation default": "Cambia il modello predefinito dell'installazione",
"Update": "Aggiorna",
"in the installation YAML, then reload Pi. See Host maintenance for instructions.": "nel file YAML dell'installazione, poi ricarica Pi. Le istruzioni sono in Manutenzione host.",
"Available models": "Modelli disponibili",
"Choose your working model in the context bar. Each enabled model should be verified for both Core and Administration.": "Scegli il modello di lavoro nella barra del contesto. Ogni modello abilitato deve essere verificato sia per Core sia per Amministrazione.",
"Catalog summary": "Riepilogo del catalogo",
"{database}: {diagnostic}": "{database}: {diagnostic}",
"{succeeded} succeeded. Failed: {databases}.": "{succeeded} riuscite. Non riuscite: {databases}.",
"{count} of {total}": "{count} di {total}",
+2
View File
@@ -1,4 +1,6 @@
export const itCore: Record<string, string> = {
"Type your question below. Review each step before creating your datamart.": "Scrivi la domanda qui sotto. Rivedi ogni passaggio prima di creare il datamart.",
"Working context": "Contesto di lavoro",
"Session startup failed. Check configuration and connectivity, then Resume the session.": "Avvio della sessione non riuscito. Controlla configurazione e connessione, quindi riprendi la sessione.",
"Session services are not ready. Check configuration and connectivity, then try again.": "I servizi della sessione non sono pronti. Controlla configurazione e connessione, quindi riprova.",
"Session could not be resumed. Check configuration and connectivity, then try again.": "Impossibile riprendere la sessione. Controlla configurazione e connessione, quindi riprova.",
+57 -33
View File
@@ -10,7 +10,7 @@
body.thot-full-document { margin: 0; }
/*
* GSD theme — mirrors the Omics Portal design system (Manrope + Fraunces, warm
* Product theme — shares the Omics Portal identity (Manrope, warm
* off-white surfaces, #cb333b red). Tokens are RAW OKLCH triplets (L C H) so the
* Tailwind v3 config can wrap them as `oklch(var(--token) / <alpha-value>)` and
* opacity utilities (bg-primary/80, …) keep working. Dark tokens follow either a
@@ -20,9 +20,14 @@ body.thot-full-document { margin: 0; }
@layer base {
#root, [data-base-ui-portal] {
color-scheme: light;
--font-sans: "Manrope", -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, Arial, sans-serif;
--font-heading: "Fraunces", "Source Serif Pro", Georgia, "Times New Roman", serif;
--font-sans: "Manrope Variable", "Manrope", -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, Arial, sans-serif;
--font-heading: var(--font-sans);
--font-mono: ui-monospace, "SF Mono", "Cascadia Code", Menlo, Consolas, monospace;
--text-page: 1.5rem;
--text-section: 1.25rem;
--text-body: 1rem;
--text-control: 0.875rem;
--text-meta: 0.75rem;
/* Layered elevation, tinted with the foreground hue so depth reads warm on
warm surfaces. Borders mark structure; shadows mark elevation. */
@@ -49,11 +54,11 @@ body.thot-full-document { margin: 0; }
--destructive: 0.5587 0.1881 23.2;
--destructive-foreground: 0.9918 0.0011 17.2;
--border: 0.9093 0.0035 354.7;
--input: 0.9093 0.0035 354.7;
--input: 0.64 0.008 354.7;
--ring: 0.5587 0.1881 23.2;
--success: 0.7577 0.1581 165.0;
--warning: 0.8523 0.1386 78.9;
--info: 0.7035 0.1128 221.3;
--success: 0.46 0.095 160;
--warning: 0.48 0.09 70;
--info: var(--muted-foreground);
/* Current navigation shares Instrument Red's hue, with lower chroma and
higher lightness so selection reads as a muted location signal. */
--nav-active: 0.9250 0.0520 23.2;
@@ -80,8 +85,8 @@ body.thot-full-document { margin: 0; }
--card-foreground: 0.9310 0 90;
--popover: 0.2350 0 90;
--popover-foreground: 0.9310 0 90;
--primary: 0.6023 0.1848 20.3;
--primary-foreground: 0.9310 0 90;
--primary: 0.73 0.15 23.2;
--primary-foreground: 0.2178 0.005 23.2;
--primary-hover: 0.6897 0.1779 16.9;
--secondary: 0.2972 0 90;
--secondary-foreground: 0.9310 0 90;
@@ -89,11 +94,13 @@ body.thot-full-document { margin: 0; }
--muted-foreground: 0.7826 0 90;
--accent: 0.3485 0 90;
--accent-foreground: 0.9310 0 90;
--destructive: 0.6023 0.1848 20.3;
--destructive: 0.73 0.15 23.2;
--destructive-foreground: 0.9310 0 90;
--border: 0.3715 0 90;
--input: 0.3715 0 90;
--input: 0.62 0.008 23.2;
--ring: 0.6897 0.1779 16.9;
--success: 0.78 0.10 160;
--warning: 0.82 0.10 75;
--nav-active: 0.3350 0.0550 20.3;
--nav-active-hover: 0.3750 0.0650 20.3;
--nav-active-foreground: 0.8900 0.0700 20.3;
@@ -150,7 +157,22 @@ body.thot-full-document { margin: 0; }
font-feature-settings: "ss01", "cv11";
}
:where(#root, [data-base-ui-portal]) :is(h1, h2, h3, h4) {
:where(#root, [data-base-ui-portal]) :is(input, textarea)::placeholder {
color: oklch(var(--muted-foreground));
opacity: 1;
}
:where(#root, [data-base-ui-portal]) :is(button, input, select, textarea, summary):focus-visible {
outline: 2px solid oklch(var(--ring));
outline-offset: 2px;
}
/* The composer already exposes keyboard focus on its enclosing focus-within frame. */
#root textarea.thot-composer-input:focus-visible {
outline: none;
}
:where(#root, [data-base-ui-portal]) :where(h1, h2, h3, h4, h5, h6) {
font-family: var(--font-heading);
font-optical-sizing: auto;
letter-spacing: -0.015em;
@@ -221,8 +243,8 @@ body.thot-full-document { margin: 0; }
}
.thot-full-header__brand { font: 600 1.25rem/1.2 var(--font-heading); }
.thot-full-header__controls { display: flex; align-items: center; flex-wrap: wrap; gap: 0.25rem; min-width: 0; }
.thot-full-header__user { max-width: 15rem; min-width: 0; font-size: 0.8125rem; }
.thot-full-header__error { flex-basis: 100%; font-size: 0.8125rem; color: oklch(var(--destructive)); }
.thot-full-header__user { max-width: 15rem; min-width: 0; font-size: var(--text-control); }
.thot-full-header__error { flex-basis: 100%; font-size: var(--text-control); color: oklch(var(--destructive)); }
.thot-shell-select {
min-height: 2rem;
max-width: 9rem;
@@ -231,7 +253,7 @@ body.thot-full-document { margin: 0; }
border-radius: var(--radius);
background: oklch(var(--background));
color: oklch(var(--foreground));
font: 600 0.8125rem/1.5 var(--font-sans);
font: 600 var(--text-control)/1.5 var(--font-sans);
}
.thot-shell-select:focus-visible { outline: 3px solid oklch(var(--ring) / 0.5); outline-offset: 2px; }
.thot-user-menu {
@@ -269,22 +291,21 @@ body.thot-full-document { margin: 0; }
padding: 0 1rem 0.75rem;
}
/* Standard micro-label: panel headers, nav sections, meta rows. Set in the
mono register so labels/meta read as a distinct typographic layer from the
sans body prose (mono needs less tracking than the sans did). */
/* UI labels use the same family as controls; monospace is for technical data. */
.thot-label {
font-family: var(--font-mono);
font-size: 0.6875rem;
font-family: var(--font-sans);
font-size: var(--text-meta);
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
letter-spacing: normal;
text-transform: none;
color: oklch(var(--muted-foreground));
}
.thot-prose {
color: oklch(var(--foreground));
font-size: 0.9375rem;
line-height: 1.65;
font-size: var(--text-body);
line-height: 1.6;
overflow-wrap: anywhere;
}
.thot-prose > :first-child { margin-top: 0; }
.thot-prose > :last-child { margin-bottom: 0; }
@@ -296,9 +317,10 @@ body.thot-full-document { margin: 0; }
margin: 1.1em 0 0.5em;
letter-spacing: -0.01em;
}
.thot-prose h1 { font-size: 1.45rem; }
.thot-prose h2 { font-size: 1.2rem; }
.thot-prose h3 { font-size: 1.02rem; }
.thot-prose h1 { font-size: var(--text-page); }
.thot-prose h2 { font-size: var(--text-section); }
.thot-prose h3 { font-size: var(--text-body); }
.thot-prose :where(p, ul, ol, blockquote) { max-width: 75ch; }
.thot-prose ul, .thot-prose ol { margin: 0.6em 0; padding-left: 1.3em; }
.thot-prose li { margin: 0.25em 0; }
.thot-prose a { color: oklch(var(--primary)); text-decoration: underline; text-underline-offset: 2px; }
@@ -320,8 +342,10 @@ body.thot-full-document { margin: 0; }
}
.thot-prose pre code { background: transparent; padding: 0; }
.thot-prose blockquote {
border-left: 2px solid oklch(var(--border));
padding-left: 0.9em;
border: 1px solid oklch(var(--border));
border-radius: var(--radius);
background: oklch(var(--muted) / 0.4);
padding: 0.75em 1em;
color: oklch(var(--muted-foreground));
margin: 0.7em 0;
}
@@ -339,10 +363,10 @@ body.thot-full-document { margin: 0; }
.thot-prose td { border-top: 1px solid oklch(var(--border) / 0.6); font-variant-numeric: tabular-nums; }
.thot-prose th {
background: oklch(var(--muted));
font-size: 0.72rem;
font-size: var(--text-control);
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
letter-spacing: normal;
text-transform: none;
color: oklch(var(--muted-foreground));
}
.thot-activity-prose,
@@ -409,7 +433,7 @@ body.thot-full-document { margin: 0; }
.thot-database-grid, .thot-preview-grid,
:is(.thot-database-grid, .thot-preview-grid) .ag-theme-alpine {
--ag-font-family: var(--font-sans);
--ag-font-size: 0.8125rem;
--ag-font-size: var(--text-control);
--ag-background-color: oklch(var(--card));
--ag-foreground-color: oklch(var(--foreground));
--ag-header-background-color: oklch(var(--muted) / 0.72);
+1
View File
@@ -1,6 +1,7 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { App } from "./App";
import "@fontsource-variable/manrope";
import "./index.css";
import "./widgets";
@@ -125,7 +125,7 @@ test("option A has one collapsible global context and preserves the Core draft a
renderShell();
const composer = await screen.findByRole("textbox", { name: /new question/i });
await userEvent.type(composer, "Keep my original Core question");
await userEvent.click(screen.getByRole("button", { name: /WORKING CONTEXT/ }));
await userEvent.click(screen.getByRole("button", { name: /Working context/ }));
expect(screen.getAllByRole("combobox", { name: "Workspace" })).toHaveLength(1);
expect(screen.getAllByRole("combobox", { name: "Model" })).toHaveLength(1);
await userEvent.selectOptions(screen.getByRole("combobox", { name: "Workspace" }), "beta");
@@ -159,7 +159,7 @@ test("invalid remembered choices do not silently fall back and gate both Core an
test("workspace and model stay locked during Core and unlock after it finishes", async () => {
renderShell();
await screen.findByRole("textbox", { name: /new question/i });
await userEvent.click(screen.getByRole("button", { name: /WORKING CONTEXT/ }));
await userEvent.click(screen.getByRole("button", { name: /Working context/ }));
act(() => useSessionStore.getState().setAgentActive(true));
expect(screen.getByRole("combobox", { name: "Workspace" })).toBeDisabled();
expect(screen.getByRole("combobox", { name: "Model" })).toBeDisabled();
@@ -182,7 +182,7 @@ test("dirty Memory blocks workspace and model changes, including after a failed
const page = await screen.findByRole("main", { name: "Memory management" });
await userEvent.click(within(page).getByRole("button", { name: "New card" }));
await userEvent.type(within(page).getByLabelText("Title"), "Do not lose this");
await userEvent.click(screen.getByRole("button", { name: /WORKING CONTEXT/ }));
await userEvent.click(screen.getByRole("button", { name: /Working context/ }));
await userEvent.selectOptions(screen.getByRole("combobox", { name: "Workspace" }), "beta");
await userEvent.selectOptions(screen.getByRole("combobox", { name: "Model" }), "test/other");
expect(workspacePreferences.load()).toMatchObject({ workspaceId: "alpha", model: "test" });
@@ -240,7 +240,7 @@ test("a successful Memory save releases the navigation guard and the context loc
await userEvent.click(within(page).getByRole("button", { name: "New card" }));
await userEvent.type(within(page).getByLabelText("Title"), "Saved card");
await userEvent.type(within(page).getByLabelText("Scope"), "This workspace");
await userEvent.click(screen.getByRole("button", { name: /WORKING CONTEXT/ }));
await userEvent.click(screen.getByRole("button", { name: /Working context/ }));
await userEvent.click(within(page).getByRole("button", { name: "Save card" }));
expect(screen.getByRole("combobox", { name: "Model" })).toBeDisabled();
await userEvent.click(screen.getByRole("button", { name: "Return to session" }));
@@ -257,7 +257,7 @@ test("Admin generation locks the context and Core submission across page navigat
renderShell(client);
const composer = await screen.findByRole("textbox", { name: /new question/i });
await userEvent.type(composer, "Wait for Admin to finish");
await userEvent.click(screen.getByRole("button", { name: /WORKING CONTEXT/ }));
await userEvent.click(screen.getByRole("button", { name: /Working context/ }));
await act(async () => {
client.setQueryData(["description-generation-runs", 50], [{ id: "r1", status: "running" }]);
});
+1 -1
View File
@@ -65,7 +65,7 @@ describe("authenticated shell permissions", () => {
const memory = screen.getByRole("main", { name: "Memory management" });
expect(memory).toBeVisible();
expect(within(memory).queryByRole("combobox", { name: "Workspace" })).not.toBeInTheDocument();
await userEvent.click(screen.getByRole("button", { name: /WORKING CONTEXT/ }));
await userEvent.click(screen.getByRole("button", { name: /Working context/ }));
expect(screen.getByRole("combobox", { name: "Workspace" })).toHaveValue("default");
});
test("does not use the legacy installation default as a preprocessing workspace", async () => {
@@ -177,7 +177,7 @@ test("model selector shows the three Pi-enabled models and stores the selected p
renderShell();
await screen.findByRole("textbox", { name: /new question/i });
await userEvent.click(screen.getByRole("button", { name: /WORKING CONTEXT/ }));
await userEvent.click(screen.getByRole("button", { name: /Working context/ }));
const select = screen.getByRole("combobox", { name: "Model" });
await waitFor(() => {
expect(within(select).getAllByRole("option").map((option) => option.textContent)).toEqual([
@@ -36,13 +36,21 @@ const resumeResult = (id: string, alreadyActive = false) =>
class ControlledResizeObserver {
static instances: ControlledResizeObserver[] = [];
private targets = new Set<Element>();
constructor(private readonly callback: ResizeObserverCallback) {
ControlledResizeObserver.instances.push(this);
}
observe = vi.fn();
disconnect = vi.fn();
observe = vi.fn((target: Element) => { this.targets.add(target); });
disconnect = vi.fn(() => { this.targets.clear(); });
trigger() {
this.callback([], this as unknown as ResizeObserver);
const entries: ResizeObserverEntry[] = [...this.targets].map(target => ({
target,
contentRect: new DOMRect(0, 0, target.clientWidth, target.clientHeight),
borderBoxSize: [],
contentBoxSize: [],
devicePixelContentBoxSize: [],
}));
if (entries.length) this.callback(entries, this as unknown as ResizeObserver);
}
}
+12 -6
View File
@@ -884,7 +884,7 @@ export function AppShell({ canLogout }: AppShellProps) {
{activeSession?.question && (
<header className="sticky top-0 z-10 -mx-6 -mt-8 border-b border-border/60 bg-background/95 px-6 pb-3 pt-8 backdrop-blur supports-[backdrop-filter]:bg-background/80">
<p className="thot-label mb-0.5 text-muted-foreground">{t("Question")}</p>
<h2 className="font-heading text-[0.95rem] font-semibold leading-snug text-foreground break-words">
<h2 className="font-heading text-base font-semibold leading-snug text-foreground break-words">
{activeSession.question}
</h2>
</header>
@@ -951,7 +951,7 @@ export function AppShell({ canLogout }: AppShellProps) {
<ArchiveNavigation surface={activeSurface} forceDrawer={activeSurface === "core" && showActivity}>
<aside aria-label={t("Session navigation")} className="flex w-64 shrink-0 flex-col bg-sidebar">
<div className="thot-session-navigation__header relative text-center">
<h1 className="font-heading text-xl font-semibold leading-none tracking-tight text-foreground">
<h1 className="thot-wordmark--sidebar font-heading text-[2rem] font-semibold leading-none tracking-tight text-foreground">
Thoth<span className="text-primary">II</span>
</h1>
<p className="thot-label mt-1.5">{t("Datamart Builder with")}<br />{t("Human-in-the-loop review")}</p>
@@ -1114,12 +1114,16 @@ export function AppShell({ canLogout }: AppShellProps) {
className="flex min-h-0 flex-1 flex-col"
>
{canReadAllSessions && showingAllSessions && (
<p className="mx-4 mb-2 mt-2 text-xs font-medium text-amber-700 dark:text-amber-400">{t("Administrator view: all sessions")}</p>
<p className="mx-4 mb-2 mt-2 text-xs font-medium text-muted-foreground">
{t("Administrator view: all sessions")}
</p>
)}
{/* L1 — rail title */}
<div className="px-4 pb-1.5 pt-1">
<span className="thot-label text-[0.8rem] font-bold tracking-[0.18em] text-primary">{t("Sessions")}</span>
<span className="text-sm font-semibold text-foreground">
{t("Sessions")}
</span>
</div>
<div className="flex items-center justify-between px-4 pb-2">
@@ -1291,10 +1295,12 @@ function EmptyState() {
const { t } = useI18n();
return (
<div className="flex min-h-[72vh] flex-col items-center justify-center text-center">
<span className="font-heading text-5xl font-semibold tracking-tight text-foreground">
<span className="thot-wordmark--core font-heading text-5xl font-semibold tracking-tight text-foreground">
Thoth<span className="text-primary">II</span>
</span>
<p className="mt-4 max-w-md text-balance text-[0.95rem] leading-relaxed text-muted-foreground">{t("Build datamarts from your database through a guided, human-in-the-loop workflow. Type your question in the box below to begin a session.")}</p>
<p className="mt-4 max-w-md text-balance text-base leading-relaxed text-muted-foreground">
{t("Type your question below. Review each step before creating your datamart.")}
</p>
</div>
);
}
+1 -1
View File
@@ -54,7 +54,7 @@ export function CentralStatus({ working }: { working: boolean }) {
<li
key={index}
className={[
"flex items-baseline gap-2 font-mono text-[0.72rem] leading-relaxed",
"flex items-baseline gap-2 font-mono text-xs leading-relaxed",
isLast ? "text-foreground/80" : "text-muted-foreground",
].join(" ")}
>
@@ -281,7 +281,7 @@ test("renders Fleet Ledger with real metrics, conceptual row tooltips, and persi
expect(screen.queryByRole("button", { name: "Back to workspace" })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: /Add database/i })).not.toBeInTheDocument();
expect(screen.getByRole("columnheader", { name: /Catalog status/ })).toBeVisible();
const summary = screen.getByRole("region", { name: "Fleet summary" });
const summary = screen.getByRole("region", { name: "Catalog summary" });
expect(await within(summary).findByText("2,275")).toBeVisible();
expect(within(summary).getByText("75%")).toBeVisible();
const modelSelector = await screen.findByRole("combobox", { name: "Global LLM model" });
@@ -1083,7 +1083,7 @@ export function DatabaseManagementPage({
<span
aria-hidden="true"
className={connectionTestResult?.outcome === "success"
? "grid size-9 shrink-0 place-items-center rounded-full bg-[oklch(var(--success)/0.14)] text-[oklch(0.47_0.12_165)]"
? "grid size-9 shrink-0 place-items-center rounded-full bg-[oklch(var(--success)/0.14)] text-success"
: "grid size-9 shrink-0 place-items-center rounded-full bg-destructive/10 text-destructive"
}
>
@@ -1343,7 +1343,7 @@ export function DatabaseManagementPage({
)}
kpis={(
<FleetLedgerKpiStrip
title={metricsDatabaseId ? t("Database summary") : t("Fleet summary")}
title={metricsDatabaseId ? t("Database summary") : t("Catalog summary")}
items={[
{ id: "tables", label: t("Tables"), value: metricsValue(metrics?.tables) },
{ id: "columns", label: t("Columns"), value: metricsValue(metrics?.columns) },
@@ -68,8 +68,8 @@ function WorkspaceEvidence({ workspace, generation }: { workspace: string; gener
return <div className="flex min-h-0 flex-1 flex-col gap-5 p-6">
{list.data && <Instructions data={list.data} />}
<EvidenceSources workspace={workspace} sources={list.data?.source_reviews ?? []} />
<div className="flex flex-wrap items-end gap-3">
<label className="grid flex-1 gap-1 text-sm">{t("Search Evidence")}<input className={control} value={filters.q} onChange={e => filter("q", e.target.value)} placeholder={t("Title, identifier or content")} /></label>
<div className="thot-evidence-filters">
<label className="thot-evidence-search grid gap-1 text-sm">{t("Search Evidence")}<input className={control} value={filters.q} onChange={e => filter("q", e.target.value)} placeholder={t("Title, identifier or content")} /></label>
<Select label={t("Kind")} value={filters.kind} values={kinds} onChange={v => filter("kind", v)} />
<Select label={t("Purpose")} value={filters.purpose} values={["disambiguation", "rewriting", "schema_linking", "sql_generation"]} onChange={v => filter("purpose", v)} />
<Select label={t("Status")} value={filters.status} values={Object.keys(states)} labels={states} onChange={v => filter("status", v)} />
@@ -111,8 +111,8 @@ function Instructions({ data }: { data: EvidencePage }) {
const example = `---\nschema_version: 4\nid: evidence:my-unit\nkind: ${kind}\nlanguage: en\npurposes: [sql_generation]\n---\n\n# My Evidence\n\n${templates[kind]}\n`;
return <div className="border-b pb-5">
<p className="mb-2 text-sm font-medium">{data.pending_revision ? t("Consolidation is incomplete. The previous active content remains available.") : data.active_revision ? t("The core uses the last successfully consolidated version.") : t("Run the first consolidation to convert and activate the local archive.")}</p>
{data.location.workspace ? <><p className="mb-1 text-xs text-muted-foreground">{t("Workspace on the installation host")}</p><CopyText text={data.location.workspace} label={t("workspace path")} /></> : <p role="alert" className="text-sm">{t("A host file path is unavailable for this installation. Configure a persistent workspace-registry bind mount before editing files from the host.")}</p>}
<details className="mt-3"><summary className="cursor-pointer text-sm font-medium">{t("Edit files, consolidate and commit")}</summary>
{data.location.workspace ? <div className="mt-3"><p className="mb-1 text-xs text-muted-foreground">{t("Workspace on the installation host")}</p><CopyText text={data.location.workspace} label={t("workspace path")} /></div> : <p role="alert" className="mt-3 text-sm">{t("A host file path is unavailable for this installation. Configure a persistent workspace-registry bind mount before editing files from the host.")}</p>}
<ol className="mt-3 max-w-[75ch] list-decimal space-y-3 pl-5 text-sm">
<li>{t("Edit the files under")} <code>evidence/curated/&lt;kind&gt;/</code> {t("with your preferred editor. Keep existing IDs. Remove a unit's file to delete it; keep the curated directory.")}</li>
<li>{t("Check Git status and the diff. Saving a file does not update recall.")}{data.location.git_commands.slice(0, 2).map(command => <div className="mt-2" key={command}><CopyText text={command} label={t("Git inspection command")} /></div>)}</li>
@@ -131,7 +131,7 @@ function UnitDetail({ item, location }: { item: EvidenceUnit; location: Evidence
const source = item.provenance.kind === "manual" ? item.provenance.original : item.provenance;
const file = location.workspace ? `${location.workspace.replace(/\\/g, "/")}/evidence/${item.file}` : null;
return <article className="max-w-[75ch] space-y-5 pb-8">
<header><p className="text-xs text-muted-foreground">{t(states[item.status])} · {item.purposes.map(purpose => t(purpose.replaceAll("_", " "))).join(", ")}</p><h2 className="mt-1 font-serif text-xl font-semibold">{item.title}</h2></header>
<header><p className="text-xs text-muted-foreground">{t(states[item.status])} · {item.purposes.map(purpose => t(purpose.replaceAll("_", " "))).join(", ")}</p><h2 className="mt-1 font-sans text-xl font-semibold">{item.title}</h2></header>
{file && <CopyText text={file} label={t("Evidence file path")} />}
{item.status !== "active" && <p className="text-sm text-muted-foreground">{["invalid", "removed"].includes(item.status) ? t("The file is invalid or removed. Its last active content is shown here.") : t("This is the file version awaiting consolidation.")} {t("The core continues to use its last active version.")}</p>}
{Object.entries(item.applies_to).some(([, values]) => values.length > 0) && <section><h3 className="font-medium">{t("Scope")}</h3>{Object.entries(item.applies_to).map(([name, values]) => values.length > 0 && <p className="mt-2 break-words text-sm" key={name}><span className="capitalize">{t(name)}</span>: {values.join(", ")}</p>)}</section>}
+4 -4
View File
@@ -18,10 +18,10 @@ export function EvidenceSources({ workspace, sources }: {workspace: string; sour
const pending = sources.filter(s => s.status === "review" || s.status === "applying");
const settled = sources.filter(s => s.status !== "review" && s.status !== "applying");
const visible = showAll ? [...pending, ...settled] : pending;
return <section aria-label={t("Evidence sources")} className="rounded-md border p-4">
return <section aria-label={t("Evidence sources")} className="border-b pb-4">
<div className="flex flex-wrap items-start justify-between gap-3">
<div><h2 className="font-serif text-lg font-semibold">{t("Sources and imports")}</h2>
<p className="mt-1 max-w-[75ch] text-sm text-muted-foreground">{t("Place new Markdown drafts in the workspace’s")} <code>evidence/incoming/</code> {t("folder. Import or refresh to acquire those drafts, original local documents and configured remote sources. Review changes before they enter recall.")}</p></div>
<div><h2 className="font-sans text-lg font-semibold">{t("Sources and imports")}</h2>
<p className="mt-1 max-w-[75ch] text-sm text-muted-foreground">{t("Import sources, then review proposed changes before activation.")}</p><details className="mt-2 text-sm"><summary className="cursor-pointer font-medium">{t("Which sources are imported?")}</summary><p className="mt-2 max-w-[75ch] text-muted-foreground">{t("Place new Markdown drafts in")} <code>evidence/incoming/</code>{t(". Import also checks original local documents and configured remote sources. Review changes before they enter recall.")}</p></details></div>
<Button variant="outline" disabled={action.isPending || sources.some(s => s.status === "applying")}
onClick={() => {setNotice(""); action.mutate({action: "refresh"});}}>{t("Import or refresh sources")}</Button>
</div>
@@ -63,7 +63,7 @@ function SourceUnit({ unit }: {unit: EvidenceUnit}) {
{Object.entries(unit.payload).map(([key, value]) => <section key={key} className="mt-3"><h5 className="text-sm font-medium capitalize">{t(key.replaceAll("_", " "))}</h5>
{typeof value === "string" ? key === "sql" ? <pre className="overflow-auto rounded bg-muted p-2 text-sm">{value}</pre> : <MarkdownView source={value} /> : Array.isArray(value) ? <ul className="list-inside list-disc text-sm">{value.map(v => <li key={v}>{v}</li>)}</ul> : <dl className="text-sm">{Object.entries(value).map(([k,v]) => <div key={k}><dt className="font-mono">{k || t("(empty value)")}</dt><dd>{v}</dd></div>)}</dl>}</section>)}
<p className="mt-3 text-xs text-muted-foreground">{unit.provenance.kind === "manual" ? t("Manual declaration by {author}. Original excerpts:", { author: unit.provenance.declared_by ?? "" }) : t("Source excerpts:")}</p>
{origin?.supporting_excerpts?.map((excerpt, i) => <blockquote key={i} className="mt-2 border-l-2 pl-3 text-sm text-muted-foreground">{excerpt}</blockquote>)}
{origin?.supporting_excerpts?.map((excerpt, i) => <blockquote key={i} className="mt-2 border-l pl-3 text-sm text-muted-foreground">{excerpt}</blockquote>)}
{unit.review_items.map((r,i) => <p key={i} className="mt-2 text-sm text-destructive">{t(r.message)}</p>)}
</article>;
}
+6 -5
View File
@@ -70,6 +70,7 @@ function MemoryWorkspace({ workspace, onDirtyChange, onBusyChange }: {
const list = useQuery({ queryKey: ["memory", workspace, filters, page],
queryFn: () => listMemory(workspace, { ...filters, page, page_size: 25 }) });
const pending = useQuery({ queryKey: ["memory-pending", workspace], queryFn: () => pendingMemory(workspace) });
const hasFilters = Object.entries(filters).some(([key, value]) => !["sort", "direction"].includes(key) && Boolean(value));
const linkOptions = useQuery({ queryKey: ["memory-links", workspace, linkSearch],
queryFn: () => listMemory(workspace, { q: linkSearch, page_size: 15 }),
enabled: draft !== null && linkSearch.trim().length > 0 });
@@ -148,7 +149,7 @@ function MemoryWorkspace({ workspace, onDirtyChange, onBusyChange }: {
{list.isError && <div role="alert" className="p-6"><p>{t(apiErrorMessage(list.error))}</p><Button variant="outline" onClick={() => void list.refetch()}>{t("Retry loading")}</Button></div>}
{list.data && <>
<p className="px-6 py-3 text-xs text-muted-foreground">{list.data.total === 1 ? t("{count} card", { count: list.data.total }) : t("{count} cards", { count: list.data.total })}</p>
{list.data.items.length === 0 && <p className="px-6 py-8 text-sm text-muted-foreground">{t("No cards match these filters. Adjust the filters or create a card.")}</p>}
{list.data.items.length === 0 && <p className="px-6 py-8 text-sm text-muted-foreground">{hasFilters ? t("No cards match these filters. Adjust the filters or create a card.") : t("No memory cards yet. Create a card to add reusable knowledge.")}</p>}
<ul>{list.data.items.map(card => <li key={card.id} className="border-b">
<button disabled={busy} aria-current={selected?.id === card.id ? "true" : undefined} onClick={() => void open(card.id)}
className={`w-full px-6 py-4 text-left outline-none hover:bg-muted/50 focus-visible:ring-3 focus-visible:ring-ring/50 ${selected?.id === card.id ? "bg-muted" : ""}`}>
@@ -156,17 +157,17 @@ function MemoryWorkspace({ workspace, onDirtyChange, onBusyChange }: {
<span className="mt-1 block text-xs text-muted-foreground">{t(families[card.family])} · {card.origin === "manual" ? t("Manual") : t("Workflow")}</span>
<span className="mt-2 block text-xs">{card.indexed ? t("Index up to date") : t("Index update pending")}</span>
</button></li>)}</ul>
<nav aria-label={t("Memory pagination")} className="flex items-center justify-between gap-2 p-4">
{list.data.total > 25 && <nav aria-label={t("Memory pagination")} className="flex items-center justify-between gap-2 p-4">
<Button variant="outline" disabled={page <= 1} onClick={() => setPage(p => p - 1)}>{t("Previous")}</Button>
<span className="text-xs">{t("Page")} {page} {t("of")} {Math.max(1, Math.ceil(list.data.total / 25))}</span>
<Button variant="outline" disabled={page * 25 >= list.data.total} onClick={() => setPage(p => p + 1)}>{t("Next")}</Button>
</nav>
</nav>}
</>}
</section>
<section aria-label={t("Memory detail")} className="thot-administration-detail">
{!draft && !selected && <p className="py-8 text-sm text-muted-foreground">{t("Select a card to inspect its content, scope and provenance.")}</p>}
{selected && !draft && <div className="space-y-5">
<div><p className="text-xs text-muted-foreground">{t(families[selected.family])}</p><h2 className="mt-1 break-words font-serif text-xl font-semibold">{selected.subject}</h2></div>
<div><p className="text-xs text-muted-foreground">{t(families[selected.family])}</p><h2 className="mt-1 break-words font-sans text-xl font-semibold">{selected.subject}</h2></div>
<dl className="space-y-4 text-sm">
{(["detail", "scope", "rationale", "question", "sql"] as const).filter(k => selected[k]).map(key => <div key={key}>
<dt className="mb-1 font-semibold capitalize">{key === "detail" ? t("Content") : key === "sql" ? t("Approved SQL") : t(key)}</dt>
@@ -187,7 +188,7 @@ function MemoryWorkspace({ workspace, onDirtyChange, onBusyChange }: {
<Button variant="outline" disabled={busy} onClick={() => setDeleteConfirm(false)}>{t("Keep card")}</Button></div></div>}
</div>}
{draft && <form onSubmit={save} className="space-y-5">
<h2 className="font-serif text-xl font-semibold">{selected ? t("Edit card") : t("New Memory card")}</h2>
<h2 className="font-sans text-xl font-semibold">{selected ? t("Edit card") : t("New Memory card")}</h2>
<fieldset disabled={busy} className="space-y-4">
<label className="grid gap-1 text-sm font-medium">{t("Family")}<select aria-label={t("Family")} className={control} value={draft.family} onChange={e => {
update("family", e.target.value as MemoryFamily); if (e.target.value !== "solved_question") update("sql", "");
+1 -1
View File
@@ -50,7 +50,7 @@ function ActivityRow({ entry }: { entry: ActivityEntry }) {
const { t: translate } = useI18n();
return (
<article className="w-full min-w-0 border-b border-border/50 py-2 last:border-b-0">
<div className="mb-1 flex flex-wrap items-center gap-1 text-[0.65rem] font-semibold uppercase tracking-[0.08em] text-muted-foreground">
<div className="mb-1 flex flex-wrap items-center gap-1 text-xs font-semibold text-muted-foreground">
<span className="text-foreground/80">{translate(ACTIVITY_LABELS[entry.kind] ?? entry.kind)}</span>
{entry.phase && <span aria-label={translate("Phase {phase}", { phase: entry.phase })}>{entry.phase}</span>}
</div>
+2 -2
View File
@@ -76,14 +76,14 @@ export function NavSessions({
<span className="min-w-0 flex-1">
<span
className={[
"block truncate text-[0.8rem] leading-snug",
"block truncate text-sm leading-snug",
active ? "font-semibold text-foreground" : "font-medium text-foreground/90",
].join(" ")}
>
{label}
</span>
{showOwner && (
<span className="block truncate text-[0.65rem] text-muted-foreground">
<span className="block truncate text-xs text-muted-foreground">
{translate("Owner: {name}", { name: s.author ?? translate("Unknown") })}
</span>
)}
+1 -1
View File
@@ -90,7 +90,7 @@ test("shows every session model exposed by Core", async () => {
] })));
renderManagement();
const catalog = await screen.findByRole("region", { name: "Session models" });
const catalog = await screen.findByRole("region", { name: "Available models" });
expect(catalog).toHaveTextContent("GLM 5.3");
expect(catalog).toHaveTextContent("DeepSeek V4 Pro");
expect(catalog).toHaveTextContent("DeepSeek V4 Flash");
+23 -23
View File
@@ -52,7 +52,7 @@ function ReadinessRail({ ready, configured, credentials, smokeState }: {
function RailItem({ label, value, state }: { label: string; value: string; state: "ready" | "attention" | "idle" }) {
const { t } = useI18n();
const iconClass = state === "ready" ? "text-[oklch(var(--success))]" : state === "attention" ? "text-amber-700 dark:text-amber-400" : "text-muted-foreground";
const iconClass = state === "ready" ? "text-[oklch(var(--success))]" : state === "attention" ? "text-warning" : "text-muted-foreground";
return <div className="min-w-0">
<p className="thot-label">{t(label)}</p>
<p className={`mt-1 flex items-start gap-1.5 text-sm font-semibold ${iconClass}`}>
@@ -145,12 +145,12 @@ function PiInstructionSteps({ details }: { details: PiPlatformDetails }) {
<p className="mt-1 text-muted-foreground">{t("Using")} {t(details.terminal)}{t(", before editing files or running any lifecycle command, enter the exact checkout or worktree root. Run the platform command below, then run every command in this list from that directory:")} <code>tht</code> {t("resolves the installation descriptor and configuration from the selected project. A worktree is an independent checkout, so this prevents changing a different copy by mistake.")}</p>
<PiCodeBlock className="mt-2">{details.changeDirectoryCommand}</PiCodeBlock>
<p className="mt-2 text-muted-foreground">{t("The relevant layout is:")}</p>
<PiCodeBlock className="mt-1">project-root/
<PiCodeBlock className="mt-1">{`project-root/
├── compose.yaml
├── deploy/
│ └── &lt;installation-id&gt;/
│ └── <installation-id>/
│ └── thothii-installation.yaml
└── docker/</PiCodeBlock>
└── docker/`}</PiCodeBlock>
<p className="mt-2 text-muted-foreground">{t("The")} <code>deploy/</code> {t("directory contains the selected installation descriptor and profile files. You do not need to create or manage a")} <code>bin/</code> {t("directory:")} <code>tht</code> {t("is the installed host CLI. If discovery finds multiple descriptors, pass the intended one explicitly with")} <code>--installation &lt;absolute-path&gt;/thothii-installation.yaml</code>{t(". If")} <code>tht</code> {t("is not on your")} <code>PATH</code>{t(", install it using the installation guide.")}</p>
</li>
<li>
@@ -185,13 +185,13 @@ function PiInstructionSteps({ details }: { details: PiPlatformDetails }) {
<h4 className="font-semibold text-foreground">{t("Update the Pi version")}</h4>
<p className="mt-1 text-muted-foreground">{t("Use")} <code>tht pi update</code> {t("for the routine build: it resolves the latest stable Pi release from the registry, builds it locally, drains active sessions, and recreates only")} <code>core</code>{t(". Add")} <code>--version &lt;VERSION&gt;</code> {t("when an explicitly reviewed version is required. The digest-pinned")} <code>--source pull</code> {t("form is for an already-built, reviewed registry image; its declared version must match the digest-pinned image. Configuration changes use Reload above, not update.")}</p>
<PiCodeBlock className="mt-2">{details.updateCommand}</PiCodeBlock>
<p className="mt-2 text-[11px] text-muted-foreground">{t("Advanced: pull an immutable, digest-pinned image.")}</p>
<PiCodeBlock className="mt-1 text-[11px] text-muted-foreground">{details.pullCommand}</PiCodeBlock>
<p className="mt-2 text-xs text-muted-foreground">{t("Advanced: pull an immutable, digest-pinned image.")}</p>
<PiCodeBlock className="mt-1 text-xs text-muted-foreground">{details.pullCommand}</PiCodeBlock>
</li>
<li>
<h4 className="font-semibold text-foreground">{t("Diagnose and recover a failed operation")}</h4>
<p className="mt-1 text-muted-foreground">{t("Start with these diagnostics, in order:")}</p>
<PiCodeBlock className="mt-2 text-[11px] text-muted-foreground">{details.diagnosticCommands}</PiCodeBlock>
<PiCodeBlock className="mt-2 text-xs text-muted-foreground">{details.diagnosticCommands}</PiCodeBlock>
<dl className="mt-2 grid gap-2 text-muted-foreground">
<dt className="font-semibold text-foreground">{t("Configuration")}</dt><dd>{t("Invalid YAML, an unknown model default, or an incompatible use/adapter combination: fix the reported")} <code>modelCatalog</code> {t("field, then reload. Do not repair generated JSON directly.")}</dd>
<dt className="font-semibold text-foreground">{t("Credentials")}</dt><dd>{t("Missing or unreadable credential: correct the protected credential file selected during setup and its permissions, without printing the file.")}</dd>
@@ -200,11 +200,11 @@ function PiInstructionSteps({ details }: { details: PiPlatformDetails }) {
</dl>
<p className="mt-2 text-muted-foreground">{t("If maintenance is inactive, no recovery is needed: correct the cause and retry the original command.")}</p>
<p className="mt-2 text-muted-foreground">{t("If maintenance is active after an update, rollback the previous image:")}</p>
<PiCodeBlock className="mt-1 text-[11px] text-muted-foreground">{details.rollbackCommand}</PiCodeBlock>
<PiCodeBlock className="mt-1 text-xs text-muted-foreground">{details.rollbackCommand}</PiCodeBlock>
<p className="mt-2 text-muted-foreground">{t("If maintenance is active after a restart, fix the cause first, then recover the captured lifecycle state:")}</p>
<PiCodeBlock className="mt-1 text-[11px] text-muted-foreground">{details.recoverCommand}</PiCodeBlock>
<PiCodeBlock className="mt-1 text-xs text-muted-foreground">{details.recoverCommand}</PiCodeBlock>
<p className="mt-2 text-muted-foreground">{t("After the selected remedy, verify the installation:")}</p>
<PiCodeBlock className="mt-1 text-[11px] text-muted-foreground">{details.verificationCommands}</PiCodeBlock>
<PiCodeBlock className="mt-1 text-xs text-muted-foreground">{details.verificationCommands}</PiCodeBlock>
</li>
</ol>;
}
@@ -229,9 +229,9 @@ function PiPlatformInstructions({ hostPlatform }: { hostPlatform?: PiPlatform })
}
return (
<section aria-label={t("Pi operator workflow")} className="mt-2 grid gap-2 rounded-md border border-amber-500/30 bg-amber-500/5 p-3 text-xs">
<p className="text-muted-foreground">{t("Using the host terminal:")}</p>
<div role="tablist" aria-label={t("Pi host platform")} className="grid grid-cols-3 gap-1 rounded-md bg-muted p-1">
<section aria-label={t("Pi operator workflow")} className="thot-pi-guide grid gap-5 text-base leading-relaxed">
<p className="text-muted-foreground">{t("Run these commands on the installation host. Choose its operating system.")}</p>
<div role="tablist" aria-label={t("Pi host platform")} className="thot-workspace-area-tabs">
{piPlatforms.map((platform) => (
<button
key={platform.id}
@@ -242,7 +242,7 @@ function PiPlatformInstructions({ hostPlatform }: { hostPlatform?: PiPlatform })
aria-selected={activePlatform === platform.id}
aria-controls={`pi-${platform.id}-panel`}
tabIndex={activePlatform === platform.id ? 0 : -1}
className={activePlatform === platform.id ? "rounded bg-background px-2 py-1.5 font-semibold text-foreground shadow-sm" : "rounded px-2 py-1.5 font-semibold text-muted-foreground hover:bg-background/70 hover:text-foreground"}
className="hover:bg-muted/50"
onKeyDown={(event) => handleTabKeyDown(event, platform.id)}
onClick={() => {
setActivePlatform(platform.id);
@@ -367,7 +367,7 @@ export function PiManagement({ open, onClose, page = false }: { open: boolean; o
<p className="thot-label">{t("Bundled runtime")}</p>
<p className="mt-1 font-mono text-sm font-semibold">Pi {statusQuery.data.version ?? t("version unavailable")}</p>
</div>
<p role="status" aria-label={t("Pi readiness")} className={`rounded-md border px-2.5 py-1 text-xs font-semibold ${statusQuery.data.ready ? "border-[oklch(var(--success)/0.4)] bg-[oklch(var(--success)/0.1)] text-foreground" : "border-amber-500/35 bg-amber-500/10 text-amber-800 dark:text-amber-300"}`}>
<p role="status" aria-label={t("Pi readiness")} className={`rounded-md border px-2.5 py-1 text-xs font-semibold ${statusQuery.data.ready ? "border-[oklch(var(--success)/0.4)] bg-[oklch(var(--success)/0.1)] text-foreground" : "border-warning/35 bg-warning/10 text-warning"}`}>
{statusQuery.data.ready ? t("Ready") : t("Needs attention")}
</p>
</div>
@@ -379,7 +379,7 @@ export function PiManagement({ open, onClose, page = false }: { open: boolean; o
smokeState={smokeState}
/>
{statusQuery.data.message && <p role="status" aria-live="polite" className="rounded-md border border-amber-500/30 bg-amber-500/10 px-3 py-2 text-sm text-amber-900 dark:text-amber-200">{t(statusQuery.data.message)}</p>}
{statusQuery.data.message && <p role="status" aria-live="polite" className="rounded-md border border-warning/30 bg-warning/10 px-3 py-2 text-sm text-warning">{t(statusQuery.data.message)}</p>}
{feedback && <p role="status" aria-label={t("Pi management feedback")} aria-live="polite" className={`flex items-start gap-2 rounded-md border px-3 py-2 text-sm ${feedback.tone === "error" ? "border-destructive/30 bg-destructive/5" : "border-border bg-muted/50"}`}>
{feedback.tone === "error" ? <CircleAlert className="mt-0.5 size-4 shrink-0 text-destructive" /> : <CheckCircle2 className="mt-0.5 size-4 shrink-0 text-[oklch(var(--success))]" />}
{renderAdminMessage(t, feedback.message)}
@@ -387,14 +387,14 @@ export function PiManagement({ open, onClose, page = false }: { open: boolean; o
<section aria-label={t("Installation catalog default")} className="border-t border-border/70 pt-5">
<div className="flex flex-wrap items-baseline justify-between gap-2">
<div><h3 className="font-heading text-sm font-semibold">{t("Installation catalog default")}</h3><p className="mt-1 text-sm text-muted-foreground">{t("This value is read from")} <code>modelCatalog.defaults.interaction</code>{t(". Change the installation YAML and reload Pi; this page never creates a second model default.")}</p></div>
<div><h3 className="font-heading text-sm font-semibold">{t("Installation catalog default")}</h3><p className="mt-1 text-sm text-muted-foreground">{t("Used for Core and Administration when no model has been chosen. The test below checks this default, not your current selection.")}</p><details className="mt-2 text-sm"><summary className="cursor-pointer font-medium">{t("Change the installation default")}</summary><p className="mt-2 text-muted-foreground">{t("Update")} <code>modelCatalog.defaults.interaction</code> {t("in the installation YAML, then reload Pi. See Host maintenance for instructions.")}</p></details></div>
</div>
{catalogReady ? (
<>
<div className="mt-4 grid gap-3 text-sm sm:grid-cols-3">
<div><p className="thot-label">{t("Provider")}</p><p className="mt-1 font-mono font-semibold">{statusQuery.data.config.provider}</p></div>
<div><p className="thot-label">{t("Model")}</p><p className="mt-1 font-mono font-semibold">{statusQuery.data.config.model}</p></div>
<div><p className="thot-label">{t("Reasoning")}</p><p className="mt-1 font-mono font-semibold">{statusQuery.data.config.reasoning}</p></div>
<div><p className="thot-label">{t("Reasoning")}</p><p className="mt-1 font-semibold">{statusQuery.data.config.reasoning}</p></div>
</div>
<div className="mt-4 flex flex-wrap gap-2">
<Button size="sm" variant="outline" disabled={smokeMutation.isPending} onClick={testCatalogDefault}>{smokeMutation.isPending ? <LoaderCircle className="animate-spin motion-reduce:animate-none" /> : <FlaskConical />}{t("Test catalog default")}</Button>
@@ -402,7 +402,7 @@ export function PiManagement({ open, onClose, page = false }: { open: boolean; o
</>
) : (
<div className="mt-4">
<div role="alert" aria-label={t("Pi configuration incomplete")} className="rounded-md border border-amber-500/30 bg-amber-500/10 p-4 text-sm text-amber-900 dark:text-amber-200">
<div role="alert" aria-label={t("Pi configuration incomplete")} className="rounded-md border border-warning/30 bg-warning/10 p-4 text-sm text-warning">
<p className="font-semibold">{t("The catalog default is unavailable.")}</p>
<p className="mt-1">{t("Fix")} <code>modelCatalog.defaults.interaction</code> {t("in the installation YAML, reload Pi, then retry this panel.")}</p>
</div>
@@ -414,14 +414,14 @@ export function PiManagement({ open, onClose, page = false }: { open: boolean; o
)}
</section>
<section aria-label={t("Session models")} className="border-t border-border/70 pt-5">
<section aria-label={t("Available models")} className="border-t border-border/70 pt-5">
<div className="flex flex-wrap items-baseline justify-between gap-2">
<div>
<h3 className="font-heading text-sm font-semibold">{t("Session models")}</h3>
<p className="mt-1 text-sm text-muted-foreground">{t("Models currently available for interactive sessions in this installation.")}</p>
<h3 className="font-heading text-sm font-semibold">{t("Available models")}</h3>
<p className="mt-1 text-sm text-muted-foreground">{t("Choose your working model in the context bar. Each enabled model should be verified for both Core and Administration.")}</p>
</div>
{modelsQuery.data && (
<span className="font-mono text-xs tabular-nums text-muted-foreground">{modelsQuery.data.models.length} {t("available")}</span>
<span className="text-xs tabular-nums text-muted-foreground">{modelsQuery.data.models.length} {t("available")}</span>
)}
</div>
{modelsQuery.isLoading ? (
+9 -8
View File
@@ -80,9 +80,9 @@ function DocBody({ doc }: { doc: SessionDocument }) {
{items.map((item, index) => (
<li key={`${item.status}:${item.subject}:${index}`} className="py-4 first:pt-0">
<span className={[
"inline-flex rounded-md px-2 py-0.5 text-[0.62rem] font-semibold uppercase tracking-wide",
"inline-flex rounded-md px-2 py-0.5 text-xs font-semibold",
item.status === "approved"
? "bg-[oklch(var(--success)/0.15)] text-[oklch(0.47_0.12_165)]"
? "bg-[oklch(var(--success)/0.15)] text-success"
: "bg-muted text-muted-foreground",
].join(" ")}>
{item.status === "approved" ? translate("Approved") : translate("Declined")}
@@ -96,7 +96,7 @@ function DocBody({ doc }: { doc: SessionDocument }) {
</div>
)}
{item.rationale && (
<div className="mt-1 border-l-2 border-border pl-3 text-sm text-muted-foreground">
<div className="mt-1 border-l border-border pl-3 text-sm text-muted-foreground">
<MarkdownView source={item.rationale} />
</div>
)}
@@ -121,7 +121,7 @@ function DocBody({ doc }: { doc: SessionDocument }) {
<ul className="flex flex-col divide-y divide-border/50">
{items.map((d, i) => (
<li key={i} className="py-3 first:pt-0">
<span className={`inline-block rounded-md px-2 py-0.5 text-[0.62rem] font-semibold uppercase tracking-wide ${decisionChipClass(d.type ?? "")}`}>
<span className={`inline-block rounded-md px-2 py-0.5 text-xs font-semibold ${decisionChipClass(d.type ?? "")}`}>
{translate((d.type ?? "decision").replace(/_/g, " "))}
</span>
{d.subject && (
@@ -147,7 +147,7 @@ function DocBody({ doc }: { doc: SessionDocument }) {
function decisionChipClass(type: string): string {
const t = type.toLowerCase();
if (t.includes("approved") || t.includes("promoted"))
return "bg-[oklch(var(--success)/0.15)] text-[oklch(0.47_0.12_165)]";
return "bg-[oklch(var(--success)/0.15)] text-success";
if (t.includes("excluded") || t.includes("rejected") || t.includes("reopened"))
return "bg-muted text-muted-foreground";
return "bg-[oklch(var(--primary)/0.1)] text-primary";
@@ -164,10 +164,11 @@ export function SessionDocumentsPanel({ session, onClose, onResume, desktopSplit
return (
<aside
aria-label={translate("Session summary")}
data-layout={desktopSplit ? "split" : "compact"}
aria-hidden={hidden || undefined}
hidden={hidden}
className={[
"flex shrink-0 flex-col border-r border-border bg-sidebar",
"thot-session-documents flex shrink-0 flex-col border-r border-border bg-sidebar",
desktopSplit
? "w-[var(--session-panel-width)] max-w-[50%]"
: "w-[30vw] max-w-[30vw]",
@@ -175,7 +176,7 @@ export function SessionDocumentsPanel({ session, onClose, onResume, desktopSplit
>
<div className="flex items-start justify-between gap-2 border-b border-border/60 px-3 py-3">
<div className="min-w-0">
<h2 className="truncate font-heading text-sm font-semibold text-foreground">
<h2 className="break-words font-heading text-sm font-semibold text-foreground">
{session.name || session.question}
</h2>
<p className="thot-label mt-0.5">{statusLabel(session)}</p>
@@ -212,7 +213,7 @@ export function SessionDocumentsPanel({ session, onClose, onResume, desktopSplit
{translate(doc.title)}
</h3>
{doc.phase !== "—" && (
<span className="rounded-md bg-muted px-1.5 py-0.5 font-mono text-[0.6rem] font-medium text-muted-foreground">
<span className="rounded-md bg-muted px-1.5 py-0.5 font-mono text-xs font-medium text-muted-foreground">
{doc.phase}
</span>
)}
+26 -6
View File
@@ -83,12 +83,32 @@ export function SteerInput({
}
// Grow the textarea to fit its content (up to a cap, then it scrolls), and shrink
// back when the text is cleared. Runs after every value change so height tracks text.
// back when cleared. Core stays mounted while hidden behind context/Admin pages,
// so a hidden scrollHeight must never become a persisted zero-pixel height.
useEffect(() => {
const el = taRef.current;
if (!el) return;
el.style.height = "auto";
el.style.height = `${Math.min(el.scrollHeight, 160)}px`;
let disposed = false;
const resize = () => {
if (disposed || el.getClientRects().length === 0) return;
el.style.height = "auto";
el.style.height = `${Math.min(el.scrollHeight, 160)}px`;
};
resize();
let lastWidth = -1;
const observer = typeof ResizeObserver === "undefined" ? undefined : new ResizeObserver(([entry]) => {
const width = entry.contentRect.width;
if (width === lastWidth) return;
lastWidth = width;
// Ignore height-only notifications from our own autosizing writes.
if (width > 0) resize();
});
observer?.observe(el);
void document.fonts?.ready.then(resize);
return () => {
disposed = true;
observer?.disconnect();
};
}, [text]);
async function submit() {
@@ -181,7 +201,7 @@ export function SteerInput({
placeholder={sessionId ? translate("Free steering (!)…") : translate("Ask a question to start a new session…")}
aria-label={sessionId ? translate("Steering") : translate("New question")}
data-awaiting-input={awaitingInput ? "true" : undefined}
className={["max-h-40 flex-1 resize-none rounded-lg bg-card px-1 py-1 text-sm leading-relaxed outline-none placeholder:text-muted-foreground", awaitingInput && "thot-awaiting-input"].filter(Boolean).join(" ")}
className={["thot-composer-input min-h-8 min-w-0 max-h-40 flex-1 resize-none rounded-lg bg-card px-1 py-1 text-sm leading-relaxed outline-none placeholder:text-muted-foreground", awaitingInput && "thot-awaiting-input"].filter(Boolean).join(" ")}
/>
<button
type="button"
@@ -297,7 +317,7 @@ export function ComposerFooter() {
)}
</FooterSelect>}
<span
className="whitespace-nowrap font-mono text-[0.7rem] tabular-nums text-muted-foreground"
className="whitespace-nowrap font-mono text-xs tabular-nums text-muted-foreground"
aria-label={translate("Input, cached, and output tokens")}
>
{formatTokenUsage(tokenUsage)}
@@ -351,7 +371,7 @@ function FooterSelect({
onChange={(e) => onChange(e.target.value)}
{...rest}
className={[
"max-w-[10rem] cursor-pointer truncate rounded-lg border border-border bg-card px-1.5 py-0.5 text-[0.7rem] text-foreground/80 outline-none transition-colors hover:bg-muted focus:border-primary/40 focus:ring-2 focus:ring-ring/15",
"max-w-[10rem] cursor-pointer truncate rounded-lg border border-border bg-card px-1.5 py-0.5 text-xs text-foreground/80 outline-none transition-colors hover:bg-muted focus:border-primary/40 focus:ring-2 focus:ring-ring/15",
className,
].filter(Boolean).join(" ")}
>
+7 -7
View File
@@ -94,19 +94,19 @@
.thot-work-area-panel__eyebrow {
margin: 0;
color: inherit;
font-family: var(--font-mono);
font-size: 0.6875rem;
font-weight: 700;
letter-spacing: 0.08em;
font-family: var(--font-sans);
font-size: var(--text-meta);
font-weight: 600;
letter-spacing: normal;
line-height: 1.35;
text-transform: uppercase;
text-transform: none;
}
.thot-work-area-panel__title {
margin: 0.25rem 0 0;
color: inherit;
font-family: var(--font-heading);
font-size: 1.35rem;
font-size: var(--text-section);
font-weight: 600;
letter-spacing: -0.025em;
line-height: 1.18;
@@ -116,7 +116,7 @@
max-width: 68ch;
margin: 0.45rem 0 0;
color: inherit;
font-size: 0.875rem;
font-size: var(--text-control);
line-height: 1.55;
}
+3 -3
View File
@@ -34,9 +34,9 @@ type DotState = "done" | "running" | "error" | "pending";
// Each state is a tinted chip; red is reserved strictly for error (the GSD theme's
// primary is already red), green = done, amber = the live phase, neutral = pending.
const DOT: Record<DotState, string> = {
done: "bg-[oklch(var(--success)/0.16)] text-[oklch(0.5_0.13_165)] ring-[oklch(var(--success)/0.32)]",
done: "bg-[oklch(var(--success)/0.16)] text-success ring-[oklch(var(--success)/0.32)]",
running:
"bg-[oklch(var(--warning)/0.24)] text-[oklch(0.46_0.11_79)] ring-[oklch(var(--warning)/0.6)]",
"bg-[oklch(var(--warning)/0.24)] text-warning ring-[oklch(var(--warning)/0.6)]",
error: "bg-destructive/15 text-destructive ring-destructive/45",
pending: "bg-transparent text-muted-foreground/70 ring-border",
};
@@ -107,7 +107,7 @@ export function WorkflowBar({
aria-current={isActive ? "step" : undefined}
className={[
"relative inline-flex size-6 items-center justify-center rounded-full",
"text-[0.625rem] font-semibold leading-none tracking-tight tabular-nums",
"text-xs font-semibold leading-none tracking-tight tabular-nums",
"ring-1 ring-inset transition-colors",
DOT[state],
].join(" ")}
+7 -9
View File
@@ -1,28 +1,26 @@
.thot-context-layout { display: flex; flex-direction: column; min-width: 0; height: var(--thoth-app-height, 100dvh); overflow: hidden; }
.thot-context-layout { --thoth-content-gutter: 1.25rem; --thoth-content-padding: 1rem; display: flex; flex-direction: column; min-width: 0; height: var(--thoth-app-height, 100dvh); overflow: hidden; }
.thot-context-layout > .thot-app-shell { flex: 1; height: auto; min-height: 0; }
.thot-context-shelf { flex: none; min-width: 0; max-height: 60%; overflow: auto; border-bottom: 1px solid oklch(var(--border)); background: oklch(var(--muted)/.5); container: working-context / inline-size; }
.thot-context-trigger { display: flex; align-items: center; gap: 16px; width: 100%; min-height: 72px; padding: 14px 24px; text-align: left; }
.thot-context-trigger { display: flex; align-items: center; gap: 16px; width: 100%; min-height: 72px; padding: 14px calc(var(--thoth-content-gutter) + var(--thoth-content-padding)); text-align: left; }
.thot-context-summary { min-width: 0; flex: 1; }
.thot-context-summary small { display: block; font: 600 .625rem/1.6 var(--font-mono); color: oklch(var(--muted-foreground)); letter-spacing: .065em; }
.thot-context-summary small { display: block; font: 500 var(--text-meta)/1.5 var(--font-sans); color: oklch(var(--muted-foreground)); }
.thot-context-summary strong { display: block; font: 600 .875rem/1.6 var(--font-sans); overflow-wrap: anywhere; }
.thot-context-summary strong span { padding-inline: 12px; color: oklch(var(--muted-foreground)); }
.thot-context-action { display: flex; align-items: center; gap: 8px; color: oklch(var(--primary)); font-size: .75rem; font-weight: 600; }
.thot-context-trigger[aria-expanded=true] .thot-context-action > svg:last-child { transform: rotate(180deg); }
.thot-context-body { padding: 0 24px 20px; }
.thot-context-body { margin: 0 var(--thoth-content-gutter) var(--thoth-content-gutter); padding: var(--thoth-content-padding); background: oklch(var(--card)); }
.thot-context-body[hidden] { display: none; }
.thot-context-explanation { max-width: none; margin: 0 0 20px; font-size: .8125rem; line-height: 1.6; color: oklch(var(--muted-foreground)); }
.thot-context-fields { display: grid; grid-template-columns: repeat(2,minmax(0,1fr)); gap: 24px; }
.thot-context-fields label { display: grid; min-width: 0; gap: 7px; font-size: .8125rem; }
.thot-context-fields label { display: grid; min-width: 0; gap: 8px; font-size: var(--text-control); }
.thot-context-fields label > span { display: flex; align-items: center; gap: 8px; font-weight: 600; }
.thot-context-fields select { min-width: 0; width: 100%; padding: 9px 12px; border: 1px solid oklch(var(--border)); border-radius: 6px; background: oklch(var(--card)); }
.thot-context-fields small, .thot-context-body > p[role] { color: oklch(var(--muted-foreground)); font-size: .75rem; line-height: 1.6; }
.thot-context-body > p[role] { color: oklch(var(--muted-foreground)); font-size: .75rem; line-height: 1.6; }
.thot-context-body > p[role] { margin-top: 12px; }
.thot-context-footer { display: flex; justify-content: flex-end; gap: 8px; margin-top: 12px; }
.thot-context-shelf :is(button,select):focus-visible { outline: 2px solid oklch(var(--ring)); outline-offset: 3px; }
.thot-context-shelf select:disabled { opacity: .65; }
@container working-context (max-width: 38rem) {
.thot-context-fields { grid-template-columns: minmax(0,1fr); gap: 14px; }
.thot-context-trigger { padding: 12px 16px; gap: 10px; }
.thot-context-body { padding: 0 16px 16px; }
.thot-context-trigger { padding-block: 12px; gap: 10px; }
.thot-context-action > span { display: none; }
}
+3 -4
View File
@@ -16,21 +16,20 @@ export function WorkingContextShelf({ context, locked, onWorkspace, onModel }: {
<button className="thot-context-trigger" aria-expanded={open} aria-controls="thoth-working-context"
onClick={() => setExpanded(!open)}>
<SlidersHorizontal size={18} aria-hidden />
<span className="thot-context-summary"><small>{translate("WORKING CONTEXT")}</small><strong>{context.workspace?.displayName ?? translate("Choose workspace")}<span aria-hidden> / </span>{context.model?.name ?? translate("Choose model")}</strong></span>
<span className="thot-context-summary"><small>{translate("Working context")}</small><strong>{context.workspace?.displayName ?? translate("Choose workspace")}<span aria-hidden> / </span>{context.model?.name ?? translate("Choose model")}</strong></span>
<span className="thot-context-action">{locked && <LockKeyhole size={15} aria-hidden />}<span>{locked ? translate("Locked") : open ? translate("Close") : translate("Change context")}</span><ChevronDown size={16} aria-hidden /></span>
</button>
<div id="thoth-working-context" hidden={!open} className="thot-context-body">
<p className="thot-context-explanation">{translate("Your last choices take precedence over installation defaults. Workspace and model are remembered independently.")}</p>
<div className="thot-context-fields">
<label><span><Layers size={15} aria-hidden />{translate("Workspace")}</span>
<select aria-label={translate("Workspace")} value={context.workspace ? context.workspaceId : ""} disabled={locked || context.loading} onChange={event => onWorkspace(event.target.value)}>
<option value="" disabled>{translate("Choose a workspace")}</option>{context.workspaces.map(workspace => <option key={workspace.id} value={workspace.id}>{workspace.displayName}</option>)}
</select><small>{translate(context.origins.workspace)}</small>
</select>
</label>
<label><span><Cpu size={15} aria-hidden />{translate("AI interaction model")}</span>
<select aria-label={translate("Model")} value={context.model ? context.modelId : ""} disabled={locked || context.loading} onChange={event => onModel(event.target.value)}>
<option value="" disabled>{translate("Choose a model")}</option>{context.models.map(model => <option key={`${model.provider}/${model.id}`} value={`${model.provider}/${model.id}`}>{model.name}</option>)}
</select><small>{translate(context.origins.model)}</small>
</select>
</label>
</div>
{locked && <p role="status">{translate("Workspace and model are locked until the current operation finishes.")}</p>}
+2 -2
View File
@@ -310,7 +310,7 @@ test("keeps validation and connection results inside their respective action car
await user.click(within(validationCard).getByRole("button", { name: "Validate workspace source" }));
const validationStatus = await within(validationCard).findByRole("status");
expect(validationStatus).toHaveTextContent("Workspace source and authentication are valid.");
expect(validationStatus).toHaveClass("text-emerald-700");
expect(validationStatus).toHaveClass("text-success");
expect(within(connectionCard).queryByText("Workspace source and authentication are valid.")).not.toBeInTheDocument();
await user.click(within(connectionCard).getByRole("button", { name: "Test workspace connections" }));
@@ -634,7 +634,7 @@ test("renders binding_ok as a green connection success", async () => {
await user.click(within(connectionCard).getByRole("button", { name: "Test workspace connections" }));
const connectionStatus = await within(connectionCard).findByRole("status");
expect(connectionStatus).toHaveTextContent("binding_ok: Installation bindings and diagnostics succeeded.");
expect(connectionStatus).toHaveClass("text-emerald-700");
expect(connectionStatus).toHaveClass("text-success");
expect(within(connectionCard).queryByRole("alert")).not.toBeInTheDocument();
});
+16 -16
View File
@@ -448,7 +448,7 @@ export function WorkspaceManager({
>
<ArrowLeft />{page ? t("Repository overview") : t("Back to Level 1")}
</Button>
<p className="mb-2 px-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">{t("Available workspaces")}</p>
<p className="mb-2 px-2 text-xs font-semibold text-muted-foreground">{t("Available workspaces")}</p>
<div className="min-h-0 flex-1 overflow-y-auto">
{workspacesQuery.isLoading && <p className="p-2 text-xs text-muted-foreground">{t("Loading workspaces…")}</p>}
{workspacesQuery.isError && (
@@ -465,7 +465,7 @@ export function WorkspaceManager({
>
<span className="block truncate">{workspace.displayName}</span>
<span className="block truncate text-xs text-muted-foreground">{workspace.id}</span>
<span className="mt-1 block text-[0.7rem] text-muted-foreground">{t(stateLabel(workspace.configurationState))}</span>
<span className="mt-1 block text-xs text-muted-foreground">{t(stateLabel(workspace.configurationState))}</span>
</button>
))}
</div>
@@ -481,10 +481,10 @@ export function WorkspaceManager({
</p>
)}
{diagnostics.length > 0 && (
<div data-testid="workspace-diagnostics" aria-live="polite" className="mb-4 grid gap-1 rounded-md border border-amber-500/30 bg-amber-500/10 px-3 py-2 text-sm">
<div data-testid="workspace-diagnostics" aria-live="polite" className="mb-4 grid gap-1 rounded-md border border-warning/30 bg-warning/10 px-3 py-2 text-sm">
{diagnostics.map((diagnostic) => (
<p key={diagnostic} className="flex items-start gap-2">
<AlertCircle className="mt-0.5 size-4 shrink-0 text-amber-700" />{renderAdminDiagnostic(t, diagnostic)}
<AlertCircle className="mt-0.5 size-4 shrink-0 text-warning" />{renderAdminDiagnostic(t, diagnostic)}
</p>
))}
</div>
@@ -603,15 +603,15 @@ export function WorkspaceManager({
<h4 className="font-heading font-semibold">{t("Validate workspace source")}</h4>
<p className="mt-1 min-h-12 text-sm leading-5 text-muted-foreground">{t("Confirms that workspace.yaml and the directories belonging to this workspace follow the supported format. This identifies source problems before activation, without changing repository files.")}</p>
{validationNotice && (
<p role="status" className="mt-3 rounded-md border border-emerald-500/30 bg-emerald-500/10 px-3 py-2 text-sm text-emerald-700">
<CheckCircle2 className="mr-1 inline size-4 text-emerald-600" />{t(validationNotice)}
<p role="status" className="mt-3 rounded-md border border-success/30 bg-success/10 px-3 py-2 text-sm text-success">
<CheckCircle2 className="mr-1 inline size-4 text-success" />{t(validationNotice)}
</p>
)}
{validationDiagnostics.length > 0 && (
<div role="alert" aria-live="polite" className="mt-3 grid gap-1 rounded-md border border-amber-500/30 bg-amber-500/10 px-3 py-2 text-sm">
<div role="alert" aria-live="polite" className="mt-3 grid gap-1 rounded-md border border-warning/30 bg-warning/10 px-3 py-2 text-sm">
{validationDiagnostics.map((diagnostic) => (
<p key={diagnostic} className="flex items-start gap-2">
<AlertCircle className="mt-0.5 size-4 shrink-0 text-amber-700" />{renderAdminDiagnostic(t, diagnostic)}
<AlertCircle className="mt-0.5 size-4 shrink-0 text-warning" />{renderAdminDiagnostic(t, diagnostic)}
</p>
))}
</div>
@@ -623,15 +623,15 @@ export function WorkspaceManager({
<h4 className="font-heading font-semibold">{t("Test workspace connections")}</h4>
<p className="mt-1 min-h-12 text-sm leading-5 text-muted-foreground">{t("Verifies the database configured in Database Management, plus this workspace's Evidence source and the installation semantic services. The result is informational.")}</p>
{connectionNotice && (
<p role="status" className="mt-3 rounded-md border border-emerald-500/30 bg-emerald-500/10 px-3 py-2 text-sm text-emerald-700">
<CheckCircle2 className="mr-1 inline size-4 text-emerald-600" />{renderAdminDiagnostic(t, connectionNotice)}
<p role="status" className="mt-3 rounded-md border border-success/30 bg-success/10 px-3 py-2 text-sm text-success">
<CheckCircle2 className="mr-1 inline size-4 text-success" />{renderAdminDiagnostic(t, connectionNotice)}
</p>
)}
{connectionDiagnostics.length > 0 && (
<div role="alert" aria-live="polite" className="mt-3 grid gap-1 rounded-md border border-amber-500/30 bg-amber-500/10 px-3 py-2 text-sm">
<div role="alert" aria-live="polite" className="mt-3 grid gap-1 rounded-md border border-warning/30 bg-warning/10 px-3 py-2 text-sm">
{connectionDiagnostics.map((diagnostic) => (
<p key={diagnostic} className="flex items-start gap-2">
<AlertCircle className="mt-0.5 size-4 shrink-0 text-amber-700" />{renderAdminDiagnostic(t, diagnostic)}
<AlertCircle className="mt-0.5 size-4 shrink-0 text-warning" />{renderAdminDiagnostic(t, diagnostic)}
</p>
))}
</div>
@@ -648,14 +648,14 @@ export function WorkspaceManager({
{authentication && <section data-testid="workspace-authentication" className="rounded-lg border border-border p-4">
<div className="flex flex-wrap items-center justify-between gap-2">
<h4 className="font-heading font-semibold">{t("Authentication")}</h4>
<span className={authentication.ready ? "text-sm font-medium text-emerald-700" : "text-sm font-medium text-amber-700"}>
<span className={authentication.ready ? "text-sm font-medium text-success" : "text-sm font-medium text-warning"}>
{authentication.ready ? t("Passed") : t("Failed")}
</span>
</div>
{authentication.checks.some(({ level }) => level === "error") && <div role="alert" className="mt-3 grid gap-1 rounded-md border border-amber-500/30 bg-amber-500/10 px-3 py-2 text-sm">
{authentication.checks.some(({ level }) => level === "error") && <div role="alert" className="mt-3 grid gap-1 rounded-md border border-warning/30 bg-warning/10 px-3 py-2 text-sm">
{authentication.checks.filter(({ level }) => level === "error").map(({ code, field, message }) => (
<p key={`${code}:${field ?? ""}`} className="flex items-start gap-2">
<AlertCircle className="mt-0.5 size-4 shrink-0 text-amber-700" />
<AlertCircle className="mt-0.5 size-4 shrink-0 text-warning" />
{code}: {field === undefined ? t(message) : `${field} — ${t(message)}`}
</p>
))}
@@ -678,7 +678,7 @@ export function WorkspaceManager({
<div key={requirement.id} className="grid gap-2 rounded-md bg-muted/30 p-3">
<div className="flex flex-wrap items-center justify-between gap-2">
<label htmlFor={`workspace-secret-${requirement.id}`} className="font-medium">{t(requirement.label)}</label>
<span className={requirement.configured ? "text-xs font-medium text-emerald-700" : "text-xs font-medium text-amber-700"}>
<span className={requirement.configured ? "text-xs font-medium text-success" : "text-xs font-medium text-warning"}>
{requirement.configured ? t("Configured") : t("Not configured")}
</span>
</div>
@@ -51,11 +51,11 @@ function diagnosticMessage(message: string): string {
function stateClasses(state: WorkspacePreprocessingState | undefined): string {
switch (state) {
case "ready": return "text-emerald-700";
case "ready": return "text-success";
case "failed": return "text-destructive";
case "required":
case "running":
case "blocked": return "text-amber-700";
case "blocked": return "text-warning";
default: return "text-muted-foreground";
}
}
@@ -74,7 +74,7 @@ function PhaseProgress({ progress }: { progress: PreprocessingProgress }) {
const step = Math.max(1, Math.min(progress.step, progress.totalSteps));
return (
<div className="col-span-2 mt-1 grid min-w-0 max-w-full gap-1">
<div className="flex min-w-0 max-w-full items-center justify-between gap-[0.5rem] text-[9px] leading-none">
<div className="flex min-w-0 max-w-full items-center justify-between gap-[0.5rem] text-xs leading-none">
<span className="min-w-0 truncate font-medium text-foreground">
{translate(PROGRESS_LABELS[progress.stage])}
</span>
@@ -98,9 +98,9 @@ function PhaseProgress({ progress }: { progress: PreprocessingProgress }) {
className={[
"h-1 rounded-full",
index < step - 1
? "bg-emerald-600/70"
? "bg-success/70"
: index === step - 1
? "bg-amber-500"
? "bg-warning"
: "bg-muted",
].join(" ")}
/>
@@ -125,10 +125,10 @@ function Diagnostic({ status }: { status: WorkspacePreprocessingStatus }) {
return (
<div
className={[
"mt-2 grid gap-2 rounded-md border p-2.5 text-[10px] leading-[1.45]",
"mt-2 grid gap-2 rounded-md border p-2.5 text-xs leading-[1.45]",
status.state === "failed"
? "border-destructive/25 bg-destructive/[0.055]"
: "border-amber-500/35 bg-amber-500/[0.07]",
: "border-warning/35 bg-warning/[0.07]",
].join(" ")}
>
<div className="grid gap-0.5">
@@ -262,7 +262,7 @@ export function WorkspacePreprocessingControl({
className="thot-preprocessing-control mt-0.5 min-w-0 max-w-full border-t border-border/90 px-0.5 pt-2"
data-presentation={presentation}
>
<p className="mb-2 px-0.5 font-mono text-[9px] font-semibold uppercase tracking-[0.08em] text-primary">
<p className="mb-2 px-0.5 text-sm font-semibold text-foreground">
{translate("Preprocessing")}</p>
{confirmingClear ? (
<div className="rounded-lg border border-destructive/25 bg-destructive/[0.045] p-2.5">
@@ -271,12 +271,12 @@ export function WorkspacePreprocessingControl({
<AlertTriangle aria-hidden="true" className="size-3.5" />
</span>
<span className="grid gap-0.5">
<strong className="text-[10px] font-semibold text-foreground">{translate("Clear derived data?")}</strong>
<small className="text-[8px] leading-tight text-muted-foreground">
<strong className="text-xs font-semibold text-foreground">{translate("Clear derived data?")}</strong>
<small className="text-xs leading-tight text-muted-foreground">
{translate("The workspace will require preprocessing.")}</small>
</span>
</div>
<div className="my-2 grid gap-1.5 border-y border-destructive/15 py-2 text-[8px]">
<div className="my-2 grid gap-1.5 border-y border-destructive/15 py-2 text-xs">
<div className="grid grid-cols-[1.2rem_minmax(0,1fr)] items-center gap-1.5">
<span className="grid size-[18px] place-items-center rounded bg-destructive/10 text-destructive">
<Trash2 aria-hidden="true" className="size-3" />
@@ -284,16 +284,16 @@ export function WorkspacePreprocessingControl({
<span><strong className="block text-foreground">{translate("Clear")}</strong><small className="text-muted-foreground">{translate("Schema/Evidence vectors, LSH and derived checkpoints")}</small></span>
</div>
<div className="grid grid-cols-[1.2rem_minmax(0,1fr)] items-center gap-1.5">
<span className="grid size-[18px] place-items-center rounded bg-emerald-600/10 text-emerald-700">
<span className="grid size-[18px] place-items-center rounded bg-success/10 text-success">
<ShieldCheck aria-hidden="true" className="size-3" />
</span>
<span><strong className="block text-foreground">{translate("Keep")}</strong><small className="text-muted-foreground">{translate("Memory and solved questions")}</small></span>
</div>
</div>
<div className="grid min-w-0 max-w-full grid-cols-[minmax(0,0.78fr)_minmax(0,1.22fr)] gap-1.5">
<Button type="button" variant="outline" size="xs" className="h-7 min-w-0 max-w-full px-[0.625rem] text-[9px]" disabled={clearMutation.isPending} onClick={() => setConfirmingClear(false)}>
<Button type="button" variant="outline" size="xs" className="h-7 min-w-0 max-w-full px-[0.625rem] text-xs" disabled={clearMutation.isPending} onClick={() => setConfirmingClear(false)}>
{translate("Cancel")}</Button>
<Button type="button" variant="destructive" size="xs" className="h-7 min-w-0 max-w-full px-[0.625rem] text-[9px]" disabled={clearMutation.isPending} onClick={() => clearMutation.mutate()}>
<Button type="button" variant="destructive" size="xs" className="h-7 min-w-0 max-w-full px-[0.625rem] text-xs" disabled={clearMutation.isPending} onClick={() => clearMutation.mutate()}>
{clearMutation.isPending ? <LoaderCircle aria-hidden="true" className="animate-spin motion-reduce:animate-none" /> : <Trash2 aria-hidden="true" />}
{clearMutation.isPending ? translate("Clearing") : translate("Clear derived data")}
</Button>
@@ -306,8 +306,8 @@ export function WorkspacePreprocessingControl({
<StatusIcon state={state} />
</span>
<div className="grid min-w-0 gap-0.5" role="status" aria-live="polite">
<strong className="text-[11px] font-semibold leading-tight text-foreground">{translate(title)}</strong>
{state !== "running" && <span className="text-[9px] leading-tight text-muted-foreground" title={diagnosticMessage(detail)}>{diagnosticMessage(detail)}</span>}
<strong className="text-xs font-semibold leading-tight text-foreground">{translate(title)}</strong>
{state !== "running" && <span className="text-xs leading-tight text-muted-foreground" title={diagnosticMessage(detail)}>{diagnosticMessage(detail)}</span>}
</div>
{progress && <PhaseProgress progress={progress} />}
<div className="col-span-2 mt-0.5 grid min-w-0 max-w-full grid-cols-[minmax(0,0.78fr)_minmax(0,1.22fr)] gap-1.5">
@@ -315,7 +315,7 @@ export function WorkspacePreprocessingControl({
type="button"
variant="outline"
size="xs"
className="h-7 min-w-0 max-w-full px-[0.5rem] text-[10px] hover:border-destructive/35 hover:bg-destructive/5 hover:text-destructive"
className="h-7 min-w-0 max-w-full px-[0.5rem] text-xs hover:border-destructive/35 hover:bg-destructive/5 hover:text-destructive"
disabled={!clearable}
title={!canManage ? translate("Database management permission is required") : undefined}
onClick={() => setConfirmingClear(true)}
@@ -325,7 +325,7 @@ export function WorkspacePreprocessingControl({
type="button"
variant="outline"
size="xs"
className="h-7 min-w-0 max-w-full px-[0.5rem] text-[10px]"
className="h-7 min-w-0 max-w-full px-[0.5rem] text-xs"
disabled={!actionable}
title={!canManage ? translate("Database management permission is required") : undefined}
onClick={() => mutation.mutate()}
@@ -5,8 +5,39 @@
min-height: 0;
height: var(--thoth-app-height, 100dvh);
overflow: hidden;
container: application / inline-size;
}
.thot-evidence-filters {
display: grid;
grid-template-columns: minmax(14rem, 2fr) repeat(3, minmax(8rem, 1fr)) auto;
align-items: end;
gap: 0.75rem;
}
.thot-evidence-filters > label { min-width: 0; }
.thot-pi-guide { max-width: 75ch; }
.thot-pi-guide ol { list-style: decimal; gap: 2rem; }
.thot-pi-guide h4 { font-size: var(--text-body); }
.thot-pi-guide pre { font-size: var(--text-control); line-height: 1.6; padding: 0.75rem 1rem; }
.thot-pi-guide p > code { font-size: var(--text-control); }
@container administration (max-width: 68rem) {
.thot-evidence-filters { grid-template-columns: repeat(3, minmax(0, 1fr)); }
.thot-evidence-search { grid-column: 1 / -1; }
}
@container administration (max-width: 30rem) {
.thot-evidence-filters { grid-template-columns: minmax(0, 1fr); }
}
.thot-session-documents[data-layout="compact"]:not([hidden]) {
position: absolute;
z-index: 35;
inset: 0;
width: 100%;
max-width: none;
border-inline-end: 0;
}
.thot-app-shell[data-administration-narrow="true"] .thot-session-documents[data-layout="compact"] { inset-block-start: 3rem; }
.thot-administration-page,
.thot-work-area-page {
display: flex;
@@ -27,26 +58,26 @@
align-items: flex-end;
justify-content: space-between;
gap: 1.25rem;
padding: 1.65rem 1.75rem 1.35rem;
padding: 1.25rem 1.5rem;
border-bottom: 1px solid oklch(var(--border));
background: oklch(var(--muted) / 0.35);
}
.thot-administration-header__copy { min-width: 0; flex: 1 1 20rem; }
.thot-administration-header h1 {
margin: 0.35rem 0 0;
margin: 0.25rem 0 0;
font-family: var(--font-heading);
font-size: 1.875rem;
line-height: 1.15;
font-size: var(--text-page);
line-height: 1.3;
font-weight: 600;
letter-spacing: -0.025em;
overflow-wrap: anywhere;
}
.thot-administration-eyebrow {
margin: 0;
font: 600 0.6875rem/1.4 var(--font-mono);
font: 500 var(--text-meta)/1.5 var(--font-sans);
color: oklch(var(--muted-foreground));
text-transform: uppercase;
letter-spacing: 0.065em;
text-transform: none;
letter-spacing: normal;
}
.thot-administration-description {
max-width: 68ch;
@@ -75,7 +106,7 @@
.thot-administration-detail { min-width: 0; padding: 1.35rem; }
.thot-administration-index :where(button[aria-current]) { background: oklch(var(--nav-active)); color: oklch(var(--nav-active-foreground)); }
.thot-administration-index button { overflow-wrap: anywhere; }
.thot-administration-section-label { padding: 1rem 1.25rem 0; font: 600 0.6875rem/1.4 var(--font-mono); text-transform: uppercase; letter-spacing: 0.06em; color: oklch(var(--muted-foreground)); }
.thot-administration-section-label { padding: 1rem 1.25rem 0; font: 600 var(--text-control)/1.5 var(--font-sans); color: oklch(var(--foreground)); }
.thot-administration-link { color: oklch(var(--primary)); text-decoration: underline; text-underline-offset: 0.2em; font-size: 0.875rem; }
.thot-administration-link:focus-visible { outline: 2px solid oklch(var(--ring)); outline-offset: 3px; }
.thot-administration-dependency { padding: 1rem 1.25rem; border-block: 1px solid oklch(var(--border)); background: oklch(var(--muted) / 0.35); font-size: 0.875rem; }
@@ -97,9 +128,9 @@
.thot-workspace-preparation { border-block-start: 1px solid oklch(var(--border)); padding-top: 1.25rem; }
.thot-workspace-preparation .thot-preprocessing-control { max-width: 40rem; }
.thot-preprocessing-control[data-presentation="page"] { padding: 1rem; margin-top: 0.75rem; }
.thot-preprocessing-control[data-presentation="page"] :where(strong, span, small, button) { font-size: 0.8125rem; line-height: 1.5; }
.thot-preprocessing-control[data-presentation="page"] :where(strong, span, small, button) { font-size: var(--text-control); line-height: 1.5; }
.thot-preprocessing-control[data-presentation="page"] button { height: auto; min-height: 2.25rem; white-space: normal; }
.thot-administration-database .thot-fleet-ledger__kpi-value { font-size: 1.125rem; }
.thot-administration-database .thot-fleet-ledger__kpi-value { font-size: var(--text-body); }
.thot-administration-database .thot-administration-dependency { flex: none; }
.thot-database-workbench { grid-template-columns: minmax(10rem, 0.55fr) minmax(0, 1.5fr); }
.thot-database-workbench form > .grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
@@ -1,12 +1,14 @@
import type { ReactNode } from "react";
import { useI18n } from "../../i18n";
import "./AdministrationPage.css";
export function AdministrationHeader({ title, description, actions, eyebrow = "Administration / Workbench" }: {
export function AdministrationHeader({ title, description, actions, eyebrow }: {
title: ReactNode; description?: ReactNode; actions?: ReactNode; eyebrow?: ReactNode;
}) {
const { t } = useI18n();
return <header className="thot-administration-header">
<div className="thot-administration-header__copy">
<p className="thot-administration-eyebrow">{eyebrow}</p>
<p className="thot-administration-eyebrow">{eyebrow ?? t("Administration")}</p>
<h1>{title}</h1>
{description && <p className="thot-administration-description">{description}</p>}
</div>
@@ -369,20 +369,20 @@ export function CatalogSyncDrawer({
key={phase}
aria-current={current ? "step" : undefined}
aria-label={`${t(label)}, ${t(status)}`}
className={`flex items-center gap-2 text-xs ${failedOutcome ? "font-semibold text-destructive" : interruptedOutcome ? "font-semibold text-amber-600" : terminalOutcome || current ? "font-semibold text-foreground" : "text-muted-foreground"}`}
className={`flex items-center gap-2 text-xs ${failedOutcome ? "font-semibold text-destructive" : interruptedOutcome ? "font-semibold text-warning" : terminalOutcome || current ? "font-semibold text-foreground" : "text-muted-foreground"}`}
>
{confirmationSkipped ? (
<Minus aria-hidden="true" className="size-3.5 text-muted-foreground" />
) : failedOutcome ? (
<X aria-hidden="true" className="size-3.5 text-destructive" />
) : interruptedOutcome ? (
<AlertTriangle aria-hidden="true" className="size-3.5 text-amber-600" />
<AlertTriangle aria-hidden="true" className="size-3.5 text-warning" />
) : terminalOutcome ? (
<Minus aria-hidden="true" className="size-3.5 text-muted-foreground" />
) : complete ? (
<CheckCircle2 aria-hidden="true" className="size-3.5 text-[oklch(var(--success))]" />
) : current ? (
<LoaderCircle aria-hidden="true" className="size-3.5 animate-spin text-amber-600" />
<LoaderCircle aria-hidden="true" className="size-3.5 animate-spin text-warning" />
) : (
<Circle aria-hidden="true" className="size-3.5" />
)}
@@ -396,16 +396,16 @@ export function CatalogSyncDrawer({
<div className="mt-5 grid grid-cols-3 gap-2">
{(["tables", "columns", "relationships"] as const).map((name) => (
<div key={name} className="rounded-md border border-border bg-muted/25 px-3 py-2">
<p className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">{name}</p>
<p className="text-xs font-semibold text-muted-foreground">{name}</p>
<p className="mt-1 text-lg font-semibold tabular-nums">{run.counts[name] ?? ""}</p>
</div>
))}
</div>
{run.state === "awaiting_confirmation" && run.plannedDiff ? (
<section className="mt-5 rounded-md border border-amber-500/50 bg-amber-500/8 p-4" aria-label={t("Destructive changes")}>
<section className="mt-5 rounded-md border border-warning/50 bg-warning/8 p-4" aria-label={t("Destructive changes")}>
<div className="flex gap-3">
<AlertTriangle className="mt-0.5 size-5 shrink-0 text-amber-600" />
<AlertTriangle className="mt-0.5 size-5 shrink-0 text-warning" />
<div>
<h3 className="font-semibold">{t("Confirm")} {deletionCount} {t("removals")}</h3>
<p className="mt-1 text-sm text-muted-foreground">{t("The database will be scanned again before anything is removed.")}</p>
@@ -520,8 +520,8 @@ export function DatabaseColumns({
<h3 className="mt-1 font-heading text-2xl font-semibold">{active.name}</h3>
<p className="mt-1 text-sm text-muted-foreground">{t("Physical schema fields are read-only. Review fields can be edited.")}</p>
{stale ? (
staleBannerOpen ? <div className="mt-4 rounded-md border border-amber-500/50 bg-amber-500/8 p-4 text-sm"><p className="font-semibold">{t("Newer column metadata is available.")}</p><p className="mt-1 text-muted-foreground">{t("Your draft is preserved until you reload.")}</p><div className="mt-3 flex gap-2"><Button type="button" variant="outline" onClick={() => void reloadColumn()}>{t("Reload latest")}</Button><Button type="button" variant="ghost" onClick={() => setStaleBannerOpen(false)}>{t("Keep editing")}</Button></div></div>
: <div className="mt-4 flex items-center justify-between gap-3 rounded-md border border-amber-500/40 bg-amber-500/8 px-4 py-3 text-sm"><span>{t("Reload the latest value before this metadata can be saved.")}</span><Button type="button" variant="outline" onClick={() => void reloadColumn()}>{t("Reload latest")}</Button></div>
staleBannerOpen ? <div className="mt-4 rounded-md border border-warning/50 bg-warning/8 p-4 text-sm"><p className="font-semibold">{t("Newer column metadata is available.")}</p><p className="mt-1 text-muted-foreground">{t("Your draft is preserved until you reload.")}</p><div className="mt-3 flex gap-2"><Button type="button" variant="outline" onClick={() => void reloadColumn()}>{t("Reload latest")}</Button><Button type="button" variant="ghost" onClick={() => setStaleBannerOpen(false)}>{t("Keep editing")}</Button></div></div>
: <div className="mt-4 flex items-center justify-between gap-3 rounded-md border border-warning/40 bg-warning/8 px-4 py-3 text-sm"><span>{t("Reload the latest value before this metadata can be saved.")}</span><Button type="button" variant="outline" onClick={() => void reloadColumn()}>{t("Reload latest")}</Button></div>
) : null}
<ColumnMetadataFieldMatrix
column={active}
@@ -656,8 +656,8 @@ export function DatabaseColumns({
<p className="thot-label">{table.name} {t("· column")}</p>
<h3 className="mt-1 break-words font-heading text-xl font-semibold">{active.name}</h3>
{stale ? (
staleBannerOpen ? <div className="mt-4 rounded-md border border-amber-500/50 bg-amber-500/8 p-4 text-sm"><p className="font-semibold">{t("Newer column metadata is available.")}</p><p className="mt-1 text-muted-foreground">{t("Your draft is preserved until you reload.")}</p><div className="mt-3 flex gap-2"><Button type="button" variant="outline" onClick={() => void reloadColumn()}>{t("Reload latest")}</Button><Button type="button" variant="ghost" onClick={() => setStaleBannerOpen(false)}>{t("Keep editing")}</Button></div></div>
: <div className="mt-4 flex items-center justify-between gap-3 rounded-md border border-amber-500/40 bg-amber-500/8 px-4 py-3 text-sm"><span>{t("Reload the latest value before this metadata can be saved.")}</span><Button type="button" variant="outline" onClick={() => void reloadColumn()}>{t("Reload latest")}</Button></div>
staleBannerOpen ? <div className="mt-4 rounded-md border border-warning/50 bg-warning/8 p-4 text-sm"><p className="font-semibold">{t("Newer column metadata is available.")}</p><p className="mt-1 text-muted-foreground">{t("Your draft is preserved until you reload.")}</p><div className="mt-3 flex gap-2"><Button type="button" variant="outline" onClick={() => void reloadColumn()}>{t("Reload latest")}</Button><Button type="button" variant="ghost" onClick={() => setStaleBannerOpen(false)}>{t("Keep editing")}</Button></div></div>
: <div className="mt-4 flex items-center justify-between gap-3 rounded-md border border-warning/40 bg-warning/8 px-4 py-3 text-sm"><span>{t("Reload the latest value before this metadata can be saved.")}</span><Button type="button" variant="outline" onClick={() => void reloadColumn()}>{t("Reload latest")}</Button></div>
) : null}
<ColumnMetadataFieldMatrix
column={active}
@@ -104,9 +104,9 @@ type StateTone = "neutral" | "info" | "success" | "warning" | "danger";
const stateToneClass: Record<StateTone, string> = {
neutral: "bg-muted-foreground/55",
info: "bg-sky-500",
success: "bg-emerald-500",
warning: "bg-amber-500",
info: "bg-muted-foreground/55",
success: "bg-success",
warning: "bg-warning",
danger: "bg-destructive",
};
@@ -128,7 +128,7 @@ function StateCell({
<span className={`size-1.5 shrink-0 rounded-full ${stateToneClass[tone]}`} aria-hidden="true" />
<span className="truncate">{t(label)}</span>
</span>
<span className={`truncate pl-3 text-[11px] ${detailClassName}`}>{t(detail)}</span>
<span className={`truncate pl-3 text-xs ${detailClassName}`}>{t(detail)}</span>
</div>
);
}
@@ -588,7 +588,7 @@ export function DatabaseRelationships({
<dt className="thot-label">{t("Column mapping")}</dt>
<dd className="mt-2 overflow-hidden rounded-md border border-border">
<table className="w-full text-left text-xs">
<thead className="bg-muted/60 font-mono uppercase tracking-wide text-muted-foreground"><tr><th className="px-3 py-2">{t("Source")}</th><th className="px-3 py-2">{t("Target")}</th></tr></thead>
<thead className="bg-muted/60 font-sans text-muted-foreground"><tr><th className="px-3 py-2">{t("Source")}</th><th className="px-3 py-2">{t("Target")}</th></tr></thead>
<tbody>{activeRelationship.columns.map((pair) => <tr key={`${pair.sourceColumnName}:${pair.targetColumnName}`} className="border-t border-border"><td className="px-3 py-2 font-mono">{pair.sourceColumnName}</td><td className="px-3 py-2 font-mono">{pair.targetColumnName}</td></tr>)}</tbody>
</table>
</dd>
@@ -603,12 +603,12 @@ export function DatabaseTables({
<h3 className="mt-1 break-words font-heading text-xl font-semibold">{activeTable.name}</h3>
{stale ? (
staleBannerOpen ? (
<div className="mt-4 rounded-md border border-amber-500/50 bg-amber-500/8 p-4 text-sm">
<div className="mt-4 rounded-md border border-warning/50 bg-warning/8 p-4 text-sm">
<p className="font-semibold">{t("A newer table description is available.")}</p>
<p className="mt-1 text-muted-foreground">{t("Your draft is preserved until you reload the current metadata.")}</p>
<div className="mt-3 flex gap-2"><Button type="button" variant="outline" onClick={() => void reloadTable()}>{t("Reload latest")}</Button><Button type="button" variant="ghost" onClick={() => setStaleBannerOpen(false)}>{t("Keep editing")}</Button></div>
</div>
) : <div className="mt-4 flex items-center justify-between gap-3 rounded-md border border-amber-500/40 bg-amber-500/8 px-4 py-3 text-sm"><span>{t("Reload the latest value before this description can be saved.")}</span><Button type="button" variant="outline" onClick={() => void reloadTable()}>{t("Reload latest")}</Button></div>
) : <div className="mt-4 flex items-center justify-between gap-3 rounded-md border border-warning/40 bg-warning/8 px-4 py-3 text-sm"><span>{t("Reload the latest value before this description can be saved.")}</span><Button type="button" variant="outline" onClick={() => void reloadTable()}>{t("Reload latest")}</Button></div>
) : null}
<div className="mt-5 grid gap-4">
<label className="grid gap-1.5 text-sm font-semibold">{t("Physical table name")}<input className="h-9 rounded-md border border-input bg-muted/35 px-3 font-mono text-xs" value={activeTable.name} readOnly /></label>
@@ -654,12 +654,12 @@ export function DatabaseTables({
<p className="text-sm text-muted-foreground">{t("Only review metadata can be changed.")}</p>
{stale ? (
staleBannerOpen ? (
<div className="mt-4 rounded-md border border-amber-500/50 bg-amber-500/8 p-4 text-sm">
<div className="mt-4 rounded-md border border-warning/50 bg-warning/8 p-4 text-sm">
<p className="font-semibold">{t("A newer table description is available.")}</p>
<p className="mt-1 text-muted-foreground">{t("Your draft is preserved until you reload the current metadata.")}</p>
<div className="mt-3 flex gap-2"><Button type="button" variant="outline" onClick={() => void reloadTable()}>{t("Reload latest")}</Button><Button type="button" variant="ghost" onClick={() => setStaleBannerOpen(false)}>{t("Keep editing")}</Button></div>
</div>
) : <div className="mt-4 flex items-center justify-between gap-3 rounded-md border border-amber-500/40 bg-amber-500/8 px-4 py-3 text-sm"><span>{t("Reload the latest value before this description can be saved.")}</span><Button type="button" variant="outline" onClick={() => void reloadTable()}>{t("Reload latest")}</Button></div>
) : <div className="mt-4 flex items-center justify-between gap-3 rounded-md border border-warning/40 bg-warning/8 px-4 py-3 text-sm"><span>{t("Reload the latest value before this description can be saved.")}</span><Button type="button" variant="outline" onClick={() => void reloadTable()}>{t("Reload latest")}</Button></div>
) : null}
<div className="mt-5 grid gap-4">
<label className="grid gap-1.5 text-sm font-semibold">{t("Physical table name")}<input className="h-9 rounded-md border border-input bg-muted/35 px-3 font-mono text-xs" value={activeTable.name} readOnly /></label>
@@ -328,7 +328,7 @@ export function DescriptionGenerationDrawer({
{apparentlyStale ? (
<p
id="description-generation-unlock-help"
className="mb-3 rounded-md border border-amber-500/35 bg-amber-500/8 px-3 py-2 text-xs leading-5 text-muted-foreground"
className="mb-3 rounded-md border border-warning/35 bg-warning/8 px-3 py-2 text-xs leading-5 text-muted-foreground"
>
{t("Unlock interrupts apparently stale work and marks the run interrupted. A live worker will prevent it.")}</p>
) : null}
@@ -411,7 +411,7 @@ export function DescriptionGenerationDrawer({
<thead>
<tr className="border-b border-border bg-muted/45">
{progressCounters.map(([name, label]) => (
<th key={name} scope="col" className="border-l border-border px-2 py-1.5 text-left text-[10px] font-semibold uppercase tracking-wide text-muted-foreground first:border-l-0">
<th key={name} scope="col" className="border-l border-border px-2 py-1.5 text-left text-xs font-semibold text-muted-foreground first:border-l-0">
{t(label)}
</th>
))}
@@ -25,7 +25,7 @@ test("uses operational body text for every available-command list", () => {
}
expect(styles).toMatch(
/\.thot-fleet-action-selector__select\s*\{[^}]*font-size:\s*0\.9375rem;/s,
/\.thot-fleet-action-selector__select\s*\{[^}]*font-size:\s*var\(--text-control\);/s,
);
expect(styles).toMatch(
/\.thot-fleet-action-selector\s*\{[^}]*flex:\s*0 0 auto;/s,
@@ -14,6 +14,9 @@
}
.thot-fleet-ledger__workspace {
--fleet-block-gutter: var(--thoth-content-gutter, 1.25rem);
--fleet-block-padding: var(--thoth-content-padding, 1rem);
--fleet-summary-inset: calc(var(--fleet-block-gutter) + var(--fleet-block-padding));
display: flex;
flex: 1;
flex-direction: column;
@@ -54,12 +57,12 @@
.thot-fleet-ledger__kpi-label {
margin: 0;
color: oklch(var(--muted-foreground));
font-family: var(--font-mono);
font-size: 0.6875rem;
font-weight: 700;
letter-spacing: 0.08em;
font-family: var(--font-sans);
font-size: var(--text-meta);
font-weight: 600;
letter-spacing: normal;
line-height: 1.35;
text-transform: uppercase;
text-transform: none;
}
.thot-fleet-ledger__title,
@@ -75,7 +78,7 @@
margin: 0;
font-family: var(--font-sans);
font-size: 1rem;
font-weight: 700;
font-weight: 600;
letter-spacing: -0.0125em;
line-height: 1.25;
white-space: nowrap;
@@ -85,7 +88,7 @@
max-width: 68ch;
margin: 0.45rem 0 0;
color: oklch(var(--muted-foreground));
font-size: 0.875rem;
font-size: var(--text-control);
line-height: 1.55;
}
@@ -101,6 +104,12 @@
.thot-fleet-ledger__header-status {
grid-area: status;
min-width: 0;
padding-inline: var(--fleet-summary-inset);
}
.thot-fleet-ledger .thot-administration-header,
.thot-fleet-ledger .thot-administration-toolbar {
padding-inline: var(--fleet-summary-inset);
}
.thot-fleet-ledger__header-status > .thot-fleet-ledger__status {
@@ -175,7 +184,6 @@
.thot-fleet-ledger__status-mark {
width: 0.45rem;
height: 0.45rem;
margin-top: 0.33rem;
flex: 0 0 auto;
border-radius: 999px;
background: var(--fleet-status-color);
@@ -191,15 +199,18 @@
.thot-fleet-ledger__status-label {
grid-column: 1 / -1;
font-size: 0.625rem;
display: flex;
align-items: center;
gap: 0.5rem;
font-size: var(--text-meta);
}
.thot-fleet-ledger__status-value {
overflow: hidden;
color: oklch(var(--foreground));
font-family: var(--font-mono);
font-size: 0.75rem;
font-weight: 700;
font-family: var(--font-sans);
font-size: var(--text-meta);
font-weight: 600;
line-height: 1.45;
text-overflow: ellipsis;
white-space: nowrap;
@@ -208,7 +219,7 @@
.thot-fleet-ledger__status-detail {
overflow: hidden;
color: oklch(var(--muted-foreground));
font-size: 0.75rem;
font-size: var(--text-meta);
line-height: 1.45;
text-overflow: ellipsis;
white-space: nowrap;
@@ -217,25 +228,25 @@
.thot-fleet-ledger__kpi-section {
display: grid;
gap: 0.625rem;
padding: 0.75rem 1.25rem 0;
margin-inline: var(--fleet-block-gutter);
padding: 0.75rem var(--fleet-block-padding) 0;
border-bottom: 1px solid oklch(var(--border));
background: oklch(var(--card));
}
.thot-fleet-ledger__kpi-strip {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 8.5rem), 1fr));
grid-template-columns: repeat(auto-fit, minmax(min(100%, 6rem), 1fr));
margin: 0;
}
.thot-fleet-ledger__kpi {
min-width: 0;
padding: 0 1rem 0.75rem 0;
padding: 0 0.75rem 0.75rem 0;
}
.thot-fleet-ledger__kpi + .thot-fleet-ledger__kpi {
padding-left: 1rem;
border-left: 1px solid oklch(var(--border));
padding-left: 0;
}
.thot-fleet-ledger__kpi-value,
@@ -246,18 +257,18 @@
.thot-fleet-ledger__kpi-value {
margin-top: 0.2rem;
color: oklch(var(--foreground));
font-family: var(--font-mono);
font-size: clamp(1.25rem, 2vw, 1.625rem);
font-family: var(--font-sans);
font-size: var(--text-body);
font-variant-numeric: tabular-nums;
font-weight: 700;
letter-spacing: -0.035em;
font-weight: 600;
letter-spacing: normal;
line-height: 1.2;
}
.thot-fleet-ledger__kpi-detail {
margin-top: 0.15rem;
color: oklch(var(--muted-foreground));
font-size: 0.75rem;
font-size: var(--text-meta);
line-height: 1.4;
}
@@ -269,7 +280,8 @@
min-height: 3rem;
gap: 0.5rem;
align-items: center;
padding: 0.5rem 1rem;
margin-inline: var(--fleet-block-gutter);
padding: 0.5rem var(--fleet-block-padding);
border-bottom: 1px solid oklch(var(--border));
background: oklch(var(--card));
}
@@ -280,7 +292,7 @@
color: oklch(var(--primary));
background: oklch(var(--primary) / 0.08);
box-shadow: 0 1px 2px oklch(var(--primary) / 0.1);
font-weight: 700;
font-weight: 600;
}
.thot-fleet-back-action:not(:disabled):hover {
@@ -326,7 +338,7 @@
color: oklch(var(--muted-foreground));
background: transparent;
font: inherit;
font-size: 0.8125rem;
font-size: var(--text-control);
text-overflow: ellipsis;
white-space: nowrap;
cursor: pointer;
@@ -346,8 +358,8 @@
max-width: 18rem;
overflow: hidden;
color: oklch(var(--foreground));
font-size: 0.8125rem;
font-weight: 650;
font-size: var(--text-control);
font-weight: 600;
text-overflow: ellipsis;
white-space: nowrap;
}
@@ -392,14 +404,14 @@
display: grid;
min-width: 7.5rem;
color: oklch(var(--foreground));
font-family: var(--font-mono);
font-size: 0.6875rem;
font-family: var(--font-sans);
font-size: var(--text-meta);
line-height: 1.35;
}
.thot-fleet-action-selector__scope span {
color: oklch(var(--muted-foreground));
font-size: 0.625rem;
font-size: var(--text-meta);
}
.thot-fleet-action-selector__select {
@@ -413,7 +425,7 @@
border-radius: var(--radius);
color: oklch(var(--foreground));
background: oklch(var(--background));
font-size: 0.9375rem;
font-size: var(--text-control);
outline: none;
}
@@ -430,7 +442,7 @@
.thot-fleet-action-selector__reason {
flex-basis: 100%;
color: oklch(var(--muted-foreground));
font-size: 0.75rem;
font-size: var(--text-meta);
line-height: 1.4;
}
@@ -438,9 +450,9 @@
min-width: 0;
overflow: hidden;
color: oklch(var(--muted-foreground));
font-family: var(--font-mono);
font-size: 0.6875rem;
letter-spacing: 0.025em;
font-family: var(--font-sans);
font-size: var(--text-meta);
letter-spacing: normal;
text-overflow: ellipsis;
white-space: nowrap;
}
@@ -453,7 +465,7 @@
min-width: 0;
min-height: 18rem;
margin-block-end: 30px;
margin-inline-start: 20px;
margin-inline: var(--fleet-block-gutter);
overflow: hidden;
border-inline-end: 1px solid oklch(var(--border));
background: oklch(var(--card));
@@ -613,6 +625,10 @@
background: oklch(var(--muted) / 0.32);
}
.thot-fleet-ledger .thot-fleet-grid-toolbar {
padding-inline: var(--fleet-block-padding);
}
@media (min-width: 48rem) {
.thot-fleet-grid-toolbar > .thot-fleet-action-selector {
flex: 0 0 auto;
@@ -628,10 +644,10 @@
}
.thot-fleet-ledger-grid .ag-header-cell-text {
font-family: var(--font-mono);
font-size: 0.6875rem;
letter-spacing: 0.055em;
text-transform: uppercase;
font-family: var(--font-sans);
font-size: var(--text-meta);
letter-spacing: normal;
text-transform: none;
}
.thot-fleet-ledger-grid .ag-row {
@@ -647,7 +663,8 @@
.thot-fleet-ledger-grid .ag-row.ag-row-selected .ag-cell,
.thot-fleet-ledger-grid .ag-row.ag-row-selected .ag-grid-pinned-left-cells,
.thot-fleet-ledger-grid .ag-row.ag-row-selected .ag-grid-pinned-right-cells {
background-color: oklch(93% 0.002 17.2) !important;
background-color: oklch(var(--nav-active)) !important;
color: oklch(var(--nav-active-foreground));
}
.thot-fleet-icon-action {
@@ -672,7 +689,7 @@
background: oklch(24% 0.014 258);
box-shadow: 0 8px 20px -10px oklch(12% 0.02 258 / 0.72);
content: attr(data-tooltip);
font-size: 0.6875rem;
font-size: var(--text-meta);
font-weight: 600;
line-height: 1.25;
text-align: center;
@@ -701,8 +718,8 @@
background: oklch(var(--popover));
box-shadow: var(--shadow-sm);
content: attr(data-tooltip);
font-size: 0.6875rem;
font-weight: 650;
font-size: var(--text-meta);
font-weight: 600;
line-height: 1.2;
opacity: 0;
pointer-events: none;
@@ -765,7 +782,7 @@
}
.thot-fleet-ledger__drawer-title {
font-size: 1.35rem;
font-size: var(--text-section);
line-height: 1.18;
}
@@ -819,17 +836,24 @@
padding-inline: 1.5rem;
}
.thot-fleet-ledger__kpi-section {
padding-inline: 1.5rem;
}
.thot-fleet-ledger__navigation,
.thot-fleet-ledger__command-bar {
padding-inline: 1.25rem;
}
}
/* Share the KPI grid's first, middle and last column anchors on wide work areas. */
@container fleet-workspace (min-width: 48rem) {
.thot-fleet-ledger__header-status .thot-fleet-ledger__status-list {
grid-template-columns: repeat(5, minmax(0, 1fr));
column-gap: 0;
}
.thot-fleet-ledger__header-status .thot-fleet-ledger__status-item:nth-child(-n + 2) {
grid-column: span 2;
}
}
@container fleet-surface (max-width: 47.999rem) {
.thot-fleet-ledger__drawer,
.thot-fleet-ledger .thot-fleet-ledger__context-panel {
@@ -949,19 +973,6 @@
min-height: 28rem;
}
.thot-fleet-ledger__kpi-strip {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.thot-fleet-ledger__kpi:nth-child(odd) {
padding-left: 0;
border-left: 0;
}
.thot-fleet-ledger__kpi:nth-child(n + 3) {
padding-top: 0.75rem;
border-top: 1px solid oklch(var(--border));
}
.thot-fleet-ledger__navigation {
flex-wrap: wrap;
@@ -163,9 +163,11 @@ export function FleetLedgerRealStatus({
className="thot-fleet-ledger__status-item"
data-tone={item.tone ?? "neutral"}
>
<span className="thot-fleet-ledger__status-mark" aria-hidden="true" />
<span className="thot-fleet-ledger__status-copy">
<span className="thot-fleet-ledger__status-label">{item.label}</span>
<span className="thot-fleet-ledger__status-label">
<span>{item.label}</span>
<span className="thot-fleet-ledger__status-mark" aria-hidden="true" />
</span>
<strong className="thot-fleet-ledger__status-value">{item.value}</strong>
{item.detail ? (
<span className="thot-fleet-ledger__status-detail">{item.detail}</span>
@@ -197,7 +199,7 @@ export function FleetLedgerKpiStrip({
aria-labelledby={titleId}
>
<h2 id={titleId} className="thot-fleet-ledger__section-label">
{title ?? t("Fleet summary")}
{title ?? t("Catalog summary")}
</h2>
<dl className="thot-fleet-ledger__kpi-strip">
{items.map((item) => (
@@ -212,7 +212,7 @@ export function SensitiveDataReviewDrawer({
: t("no sensitive match")}{t(". Coverage:")} {t(suggestion.coverage.replace("_", " "))}{t(". Observed values:")} {suggestion.observedValues}.
</p>
</div>
<span className={`rounded px-2 py-0.5 text-[11px] font-semibold ${changedFromCurrent ? "bg-amber-500/12 text-amber-800 dark:text-amber-300" : "bg-muted text-muted-foreground"}`}>
<span className={`rounded px-2 py-0.5 text-xs font-semibold ${changedFromCurrent ? "bg-warning/12 text-amber-800 dark:text-amber-300" : "bg-muted text-muted-foreground"}`}>
{changedFromCurrent ? t("Change") : t("No change")}
</span>
</li>
@@ -268,7 +268,7 @@ export function SensitivityAnalysisHistoryDrawer({
<thead>
<tr className="border-b border-border bg-muted/45">
{progressCounters.map(([name, label]) => (
<th key={name} scope="col" className="border-l border-border px-2 py-1.5 text-left text-[10px] font-semibold uppercase tracking-wide text-muted-foreground first:border-l-0">
<th key={name} scope="col" className="border-l border-border px-2 py-1.5 text-left text-xs font-semibold text-muted-foreground first:border-l-0">
{t(label)}
</th>
))}
+1 -1
View File
@@ -6,7 +6,7 @@ import type { CtePlanCte, CtePlanFilter, CtePlanV2 } from "./artifactV2";
const CHIP =
"min-w-0 max-w-full whitespace-pre-wrap [overflow-wrap:anywhere] rounded-md bg-muted px-1.5 py-0.5 font-mono text-xs text-foreground/90";
const FIELD_LABEL = "text-[0.65rem] font-semibold uppercase tracking-[0.08em] text-muted-foreground";
const FIELD_LABEL = "text-xs font-semibold text-muted-foreground";
function Chip({ children }: { children: ReactNode }) {
return <span className={CHIP}>{children}</span>;
+1 -1
View File
@@ -150,7 +150,7 @@ export function SchemaLinkingViewer({ linking }: { linking: SchemaLinking }) {
</div>
{oversized && (
<p className="text-sm text-amber-600">
<p className="text-sm text-warning">
{t("Too many nodes to display in the graph. Use the table instead.")}
</p>
)}
+1 -1
View File
@@ -82,7 +82,7 @@ function Block({ block }: { block: SqlBlock }) {
className="flex shrink-0 items-center gap-1.5 border-l border-border/70 px-2.5 text-xs text-muted-foreground transition-colors hover:bg-muted/40 hover:text-foreground"
>
{copyState === "copied" ? (
<Check className="size-3.5 text-[oklch(0.47_0.12_165)]" aria-hidden="true" />
<Check className="size-3.5 text-success" aria-hidden="true" />
) : copyState === "failed" ? (
<TriangleAlert className="size-3.5 text-destructive" aria-hidden="true" />
) : (
+1 -1
View File
@@ -9,7 +9,7 @@ export function FallbackWidget({ descriptor, onRespond }: WidgetProps) {
useUnsavedDraft(text);
return (
<div className="rounded-xl border border-border/70 bg-card p-3 shadow-sm space-y-2">
<p className="text-sm text-amber-600">{translate("Unsupported widget (kind: {kind}) — respond manually", { kind: descriptor.widget })}</p>
<p className="text-sm text-warning">{translate("Unsupported widget (kind: {kind}) — respond manually", { kind: descriptor.widget })}</p>
<pre className="text-xs font-mono overflow-auto max-h-48 rounded-lg bg-muted p-2">{JSON.stringify(descriptor, null, 2)}</pre>
<textarea aria-label={translate("Manual response")} className="w-full rounded-lg border border-input bg-card p-2 text-sm shadow-xs outline-none transition-[border-color,box-shadow] focus:border-primary/50 focus:ring-2 focus:ring-ring/20" value={text} onChange={(e) => setText(e.target.value)} />
<button className="rounded-md border border-border/70 px-2 py-1 text-sm shadow-xs transition-colors hover:bg-accent" onClick={() => {
+1 -1
View File
@@ -15,7 +15,7 @@ export function FreetextWidget({ descriptor, onRespond }: WidgetProps) {
return (
<div className="space-y-3 rounded-xl border border-border/70 bg-card p-4 shadow-sm">
{descriptor.title && (
<p className="text-[0.95rem] font-semibold leading-snug text-foreground">
<p className="text-base font-semibold leading-snug text-foreground">
{descriptor.title}
</p>
)}
+1 -1
View File
@@ -1,5 +1,5 @@
import type { WidgetProps } from "./types";
export function InfoWidget({ descriptor }: WidgetProps) {
const color = descriptor.level === "error" ? "text-red-600" : descriptor.level === "warning" ? "text-amber-600" : "text-foreground";
const color = descriptor.level === "error" ? "text-red-600" : descriptor.level === "warning" ? "text-warning" : "text-foreground";
return <p className={`text-sm leading-relaxed ${color}`}>{descriptor.text ?? descriptor.title}</p>;
}
+4 -3
View File
@@ -11,7 +11,7 @@ export function JoinReviewWidget({ descriptor, onRespond }: WidgetProps) {
<div className="space-y-4 rounded-xl border border-border/70 bg-card p-4 shadow-sm">
<div className="space-y-1.5">
{descriptor.title && (
<p className="text-[0.95rem] font-semibold leading-snug text-foreground">
<p className="text-base font-semibold leading-snug text-foreground">
{descriptor.title}
</p>
)}
@@ -45,8 +45,9 @@ export function JoinReviewWidget({ descriptor, onRespond }: WidgetProps) {
)}
{option.rationale && (
<div className="grid gap-1 sm:grid-cols-[5rem_minmax(0,1fr)] sm:gap-3">
<span className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
{translate("Rationale")}</span>
<span className="text-xs font-medium text-muted-foreground">
{translate("Rationale")}
</span>
<p className="text-sm leading-relaxed text-muted-foreground">
{option.rationale}
</p>
+9 -7
View File
@@ -29,7 +29,7 @@ export function MultiselectWidget({ descriptor, onRespond }: WidgetProps) {
return (
<div className="space-y-4 rounded-xl border border-border/70 bg-card p-4 shadow-sm">
{descriptor.title && (
<p className="text-[0.95rem] font-semibold leading-snug text-foreground">
<p className="text-base font-semibold leading-snug text-foreground">
{descriptor.title}
</p>
)}
@@ -70,18 +70,20 @@ export function MultiselectWidget({ descriptor, onRespond }: WidgetProps) {
</div>
)}
{o.rationale && (
<div className="mt-3 border-l-2 border-border/70 pl-3 text-xs text-foreground/80">
<div className="mb-1 font-semibold uppercase tracking-wide text-muted-foreground">
{translate("Reason")}</div>
<div className="mt-3 border-l border-border/70 pl-3 text-xs text-foreground/80">
<div className="mb-1 font-semibold text-muted-foreground">
{translate("Reason")}
</div>
<div className="thot-prose leading-relaxed [&>p:first-child]:mt-0 [&>p:last-child]:mb-0">
<ReactMarkdown remarkPlugins={[remarkGfm]}>{o.rationale}</ReactMarkdown>
</div>
</div>
)}
{typeof o.meta?.question_context === "string" && o.meta.question_context && (
<div className="mt-3 border-l-2 border-border/50 pl-3 text-xs text-muted-foreground">
<div className="mb-1 font-semibold uppercase tracking-wide">
{translate("Context question")}</div>
<div className="mt-3 border-l border-border/50 pl-3 text-xs text-muted-foreground">
<div className="mb-1 font-semibold">
{translate("Context question")}
</div>
<div className="thot-prose leading-relaxed [&>p:first-child]:mt-0 [&>p:last-child]:mb-0">
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{o.meta.question_context}
@@ -65,7 +65,7 @@ export function SchemaLinkingGateWidget({ descriptor, onRespond }: WidgetProps)
return (
<div className="space-y-4 rounded-xl border border-border/70 bg-card p-4 shadow-sm">
{descriptor.title && (
<p className="text-[0.95rem] font-semibold leading-snug text-foreground">{descriptor.title}</p>
<p className="text-base font-semibold leading-snug text-foreground">{descriptor.title}</p>
)}
<div className="flex flex-col gap-2">
+4 -3
View File
@@ -10,7 +10,7 @@ export function SelectWidget({ descriptor, onRespond }: WidgetProps) {
{(descriptor.title || descriptor.intro) && (
<div className="space-y-1.5">
{descriptor.title && (
<p className="text-[0.95rem] font-semibold leading-snug text-foreground">
<p className="text-base font-semibold leading-snug text-foreground">
{descriptor.title}
</p>
)}
@@ -26,8 +26,9 @@ export function SelectWidget({ descriptor, onRespond }: WidgetProps) {
>
<span>{o.label}</span>
{o.recommended && (
<span className="shrink-0 rounded-md bg-[oklch(var(--success)/0.16)] px-2 py-0.5 text-[0.65rem] font-medium text-[oklch(0.5_0.13_165)]">
{translate("Recommended")}</span>
<span className="shrink-0 rounded-md bg-[oklch(var(--success)/0.16)] px-2 py-0.5 text-xs font-medium text-success">
{translate("Recommended")}
</span>
)}
</button>
))}
+13
View File
@@ -1,6 +1,8 @@
import type { Config } from "tailwindcss";
export default {
// Match the token theme, including the portal, rather than the OS preference alone.
darkMode: ["selector", ':is(.dark, [data-bs-theme="dark"])'],
content: ["./index.html", "./src/**/*.{ts,tsx}"],
theme: {
extend: {
@@ -37,6 +39,8 @@ export default {
border: "oklch(var(--border) / <alpha-value>)",
input: "oklch(var(--input) / <alpha-value>)",
ring: "oklch(var(--ring) / <alpha-value>)",
success: "oklch(var(--success) / <alpha-value>)",
warning: "oklch(var(--warning) / <alpha-value>)",
sidebar: {
DEFAULT: "oklch(var(--sidebar) / <alpha-value>)",
foreground: "oklch(var(--sidebar-foreground) / <alpha-value>)",
@@ -53,6 +57,7 @@ export default {
md: "calc(var(--radius) - 2px)",
sm: "calc(var(--radius) - 4px)",
},
ringWidth: { 3: "3px" },
boxShadow: {
xs: "var(--shadow-xs)",
sm: "var(--shadow-sm)",
@@ -64,6 +69,14 @@ export default {
heading: ["var(--font-heading)", "sans-serif"],
mono: ["var(--font-mono)", "monospace"],
},
fontSize: {
xs: ["var(--text-meta)", "1.5"],
sm: ["var(--text-control)", "1.5"],
base: ["var(--text-body)", "1.5"],
lg: ["var(--text-section)", "1.4"],
xl: ["var(--text-section)", "1.4"],
"2xl": ["var(--text-page)", "1.3"],
},
},
},
plugins: [],