Merge visual review into full shell and preserve bilingual layout
This commit is contained in:
@@ -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: [] });
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
Generated
+10
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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" },
|
||||
},
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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
@@ -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,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" }]);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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/<kind>/</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>}
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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", "");
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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/
|
||||
│ └── <installation-id>/
|
||||
│ └── <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 <absolute-path>/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 <VERSION></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 ? (
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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(" ")}
|
||||
>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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(" ")}
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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>}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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" />
|
||||
) : (
|
||||
|
||||
@@ -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={() => {
|
||||
|
||||
@@ -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,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>;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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: [],
|
||||
|
||||
Reference in New Issue
Block a user