style: unify workbench typography and prepare isolated visual review

This commit is contained in:
Codex
2026-09-12 22:51:58 +02:00
parent 2d1b714ebe
commit c8d276ddc6
64 changed files with 942 additions and 318 deletions
+187
View File
@@ -0,0 +1,187 @@
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) {
const writes: string[] = [];
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, 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 === "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 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 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 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.evaluate(() => document.documentElement.setAttribute("data-bs-theme", "dark"));
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);
await page.goto("/");
await expect(page.getByTestId("app-shell")).toBeVisible();
// Geometry fixture, not a replacement for acceptance inside the real Omics deployment.
await page.addStyleTag({ content: `
#root { margin-left: 240px; margin-top: 72px; --thoth-app-height: calc(100dvh - 72px); }
.px-4 { padding-left: 2.25rem !important; padding-right: 2.25rem !important; }
` });
await admin(page, "Workspace");
const shell = await page.getByTestId("app-shell").boundingBox();
expect(shell!.x).toBe(240);
expect(shell!.x + shell!.width).toBeLessThanOrEqual(1280);
expect(shell!.y + shell!.height).toBeLessThanOrEqual(1000);
expect(await inspect(page)).toEqual({ overflow: 0, serif: [], tiny: [] });
await page.screenshot({ path: testInfo.outputPath("Host-bounded-Workspace.png") });
await admin(page, "Evidence");
const search = await page.getByRole("textbox", { name: "Search Evidence" }).boundingBox();
expect(search!.width).toBeGreaterThan(180);
expect(await inspect(page)).toEqual({ overflow: 0, serif: [], tiny: [] });
});
-6
View File
@@ -4,12 +4,6 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>ThothII</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&family=Fraunces:opsz,wght@9..144,400;9..144,500;9..144,600;9..144,700&display=swap"
rel="stylesheet"
/>
</head>
<body>
<div id="root"></div>
+10
View File
@@ -8,6 +8,7 @@
"dependencies": {
"@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",
@@ -1484,6 +1485,15 @@
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@fontsource-variable/manrope": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/@fontsource-variable/manrope/-/manrope-5.3.0.tgz",
"integrity": "sha512-6D5dgokHsWDDMtmXHznKa0hK229NN+1a4BLPmUCLqcO1Pw5EEhWY5RFt0AcXnVRAljFFPfRtLkJePQj6LSsV6g==",
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@hono/node-server": {
"version": "1.19.17",
"resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.17.tgz",
+1
View File
@@ -18,6 +18,7 @@
"dependencies": {
"@base-ui/react": "^1.6.0",
"@fontsource-variable/geist": "^5.2.9",
"@fontsource-variable/manrope": "5.3.0",
"@tanstack/react-query": "^5.59.0",
"ag-grid-community": "^36.0.0",
"ag-grid-react": "^36.0.0",
+16
View File
@@ -0,0 +1,16 @@
import { defineConfig, devices } from "@playwright/test";
export default defineConfig({
testDir: "./e2e",
testMatch: "ui-visual-review.spec.ts",
timeout: 45_000,
workers: 2,
outputDir: "test-results/visual-review",
use: { ...devices["Desktop Chrome"], baseURL: "http://127.0.0.1:5183", trace: "retain-on-failure" },
webServer: {
command: "npm run dev -- --host 127.0.0.1 --port 5183 --strictPort",
url: "http://127.0.0.1:5183",
reuseExistingServer: !process.env.CI,
env: { VITE_BACKEND_URL: "/api" },
},
});
+1 -1
View File
@@ -90,7 +90,7 @@ function DefaultFallback({
<summary className="cursor-pointer text-xs text-muted-foreground hover:text-foreground">
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">
<pre className="mt-1.5 max-w-full overflow-x-auto rounded-md bg-muted px-2.5 py-1.5 font-mono text-xs text-muted-foreground">
{error.message}
</pre>
</details>
+2 -2
View File
@@ -25,8 +25,8 @@ const buttonVariants = cva(
default:
"h-8 gap-1.5 px-3.5 has-data-[icon=inline-end]:pr-2.5 has-data-[icon=inline-start]:pl-2.5",
xs: "h-6 gap-1 px-2.5 text-xs has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-7 gap-1 px-3 text-[0.8rem] has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2 [&_svg:not([class*='size-'])]:size-3.5",
lg: "h-10 gap-1.5 px-5 text-[0.9rem] has-data-[icon=inline-end]:pr-3.5 has-data-[icon=inline-start]:pl-3.5",
sm: "h-8 gap-1.5 px-3 text-sm has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2 [&_svg:not([class*='size-'])]:size-3.5",
lg: "h-10 gap-1.5 px-5 text-sm has-data-[icon=inline-end]:pr-3.5 has-data-[icon=inline-start]:pl-3.5",
icon: "size-8",
"icon-xs": "size-6 [&_svg:not([class*='size-'])]:size-3",
"icon-sm": "size-7",
+50 -30
View File
@@ -5,7 +5,7 @@
@tailwind utilities;
/*
* 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
@@ -13,9 +13,14 @@
*/
@layer base {
:root {
--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. */
@@ -42,11 +47,11 @@
--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;
@@ -73,8 +78,8 @@
--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;
@@ -82,11 +87,13 @@
--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;
@@ -141,7 +148,18 @@
font-feature-settings: "ss01", "cv11";
}
h1, h2, h3, h4 {
#root :is(input, textarea)::placeholder {
color: oklch(var(--muted-foreground));
opacity: 1;
}
#root :is(button, input, select, textarea, summary):focus-visible {
outline: 2px solid oklch(var(--ring));
outline-offset: 2px;
}
#root :where(h1, h2, h3, h4, h5, h6),
[data-slot="dialog-content"] :where(h1, h2, h3, h4, h5, h6) {
font-family: var(--font-heading);
font-optical-sizing: auto;
letter-spacing: -0.015em;
@@ -196,22 +214,21 @@
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; }
@@ -223,9 +240,10 @@
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; }
@@ -247,8 +265,10 @@
}
.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;
}
@@ -266,10 +286,10 @@
.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,
@@ -325,7 +345,7 @@
@layer components {
.thot-database-grid {
--ag-font-family: var(--font-sans);
--ag-font-size: 0.8125rem;
--ag-font-size: var(--text-control);
--ag-background-color: oklch(var(--card));
--ag-foreground-color: oklch(var(--foreground));
--ag-header-background-color: oklch(var(--muted) / 0.72);
+1
View File
@@ -1,6 +1,7 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { App } from "./App";
import "@fontsource-variable/manrope";
import "./index.css";
import "./widgets";
@@ -125,7 +125,7 @@ test("option A has one collapsible global context and preserves the Core draft a
renderShell();
const composer = await screen.findByRole("textbox", { name: /new question/i });
await userEvent.type(composer, "Keep my original Core question");
await userEvent.click(screen.getByRole("button", { name: /WORKING CONTEXT/ }));
await userEvent.click(screen.getByRole("button", { name: /Working context/ }));
expect(screen.getAllByRole("combobox", { name: "Workspace" })).toHaveLength(1);
expect(screen.getAllByRole("combobox", { name: "Model" })).toHaveLength(1);
await userEvent.selectOptions(screen.getByRole("combobox", { name: "Workspace" }), "beta");
@@ -159,7 +159,7 @@ test("invalid remembered choices do not silently fall back and gate both Core an
test("workspace and model stay locked during Core and unlock after it finishes", async () => {
renderShell();
await screen.findByRole("textbox", { name: /new question/i });
await userEvent.click(screen.getByRole("button", { name: /WORKING CONTEXT/ }));
await userEvent.click(screen.getByRole("button", { name: /Working context/ }));
act(() => useSessionStore.getState().setAgentActive(true));
expect(screen.getByRole("combobox", { name: "Workspace" })).toBeDisabled();
expect(screen.getByRole("combobox", { name: "Model" })).toBeDisabled();
@@ -182,7 +182,7 @@ test("dirty Memory blocks workspace and model changes, including after a failed
const page = await screen.findByRole("main", { name: "Memory management" });
await userEvent.click(within(page).getByRole("button", { name: "New card" }));
await userEvent.type(within(page).getByLabelText("Title"), "Do not lose this");
await userEvent.click(screen.getByRole("button", { name: /WORKING CONTEXT/ }));
await userEvent.click(screen.getByRole("button", { name: /Working context/ }));
await userEvent.selectOptions(screen.getByRole("combobox", { name: "Workspace" }), "beta");
await userEvent.selectOptions(screen.getByRole("combobox", { name: "Model" }), "test/other");
expect(workspacePreferences.load()).toMatchObject({ workspaceId: "alpha", model: "test" });
@@ -240,7 +240,7 @@ test("a successful Memory save releases the navigation guard and the context loc
await userEvent.click(within(page).getByRole("button", { name: "New card" }));
await userEvent.type(within(page).getByLabelText("Title"), "Saved card");
await userEvent.type(within(page).getByLabelText("Scope"), "This workspace");
await userEvent.click(screen.getByRole("button", { name: /WORKING CONTEXT/ }));
await userEvent.click(screen.getByRole("button", { name: /Working context/ }));
await userEvent.click(within(page).getByRole("button", { name: "Save card" }));
expect(screen.getByRole("combobox", { name: "Model" })).toBeDisabled();
await userEvent.click(screen.getByRole("button", { name: "Return to session" }));
@@ -257,7 +257,7 @@ test("Admin generation locks the context and Core submission across page navigat
renderShell(client);
const composer = await screen.findByRole("textbox", { name: /new question/i });
await userEvent.type(composer, "Wait for Admin to finish");
await userEvent.click(screen.getByRole("button", { name: /WORKING CONTEXT/ }));
await userEvent.click(screen.getByRole("button", { name: /Working context/ }));
await act(async () => {
client.setQueryData(["description-generation-runs", 50], [{ id: "r1", status: "running" }]);
});
+1 -1
View File
@@ -56,7 +56,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([
+6 -8
View File
@@ -847,7 +847,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">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>
@@ -1106,14 +1106,14 @@ 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">
<p className="mx-4 mb-2 mt-2 text-xs font-medium text-muted-foreground">
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">
<span className="text-sm font-semibold text-foreground">
Sessions
</span>
</div>
@@ -1286,13 +1286,11 @@ export function AppShell({ canLogout }: AppShellProps) {
function EmptyState() {
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="font-heading text-2xl 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">
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 className="mt-4 max-w-md text-balance text-base leading-relaxed text-muted-foreground">
Type your question below. Review each step before creating your datamart.
</p>
</div>
);
+1 -1
View File
@@ -52,7 +52,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(" ")}
>
@@ -264,7 +264,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" });
@@ -1080,7 +1080,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"
}
>
@@ -1341,7 +1341,7 @@ export function DatabaseManagementPage({
)}
kpis={(
<FleetLedgerKpiStrip
title={metricsDatabaseId ? "Database summary" : "Fleet summary"}
title={metricsDatabaseId ? "Database summary" : "Catalog summary"}
items={[
{ id: "tables", label: "Tables", value: metricsValue(metrics?.tables) },
{ id: "columns", label: "Columns", value: metricsValue(metrics?.columns) },
@@ -65,8 +65,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">Search Evidence<input className={control} value={filters.q} onChange={e => filter("q", e.target.value)} placeholder="Title, identifier or content" /></label>
<div className="thot-evidence-filters">
<label className="thot-evidence-search grid gap-1 text-sm">Search Evidence<input className={control} value={filters.q} onChange={e => filter("q", e.target.value)} placeholder="Title, identifier or content" /></label>
<Select label="Kind" value={filters.kind} values={kinds} onChange={v => filter("kind", v)} />
<Select label="Purpose" value={filters.purpose} values={["disambiguation", "rewriting", "schema_linking", "sql_generation"]} onChange={v => filter("purpose", v)} />
<Select label="Status" value={filters.status} values={Object.keys(states)} labels={states} onChange={v => filter("status", v)} />
@@ -106,8 +106,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 ? "Consolidation is incomplete. The previous active content remains available." : data.active_revision ? "The core uses the last successfully consolidated version." : "Run the first consolidation to convert and activate the local archive."}</p>
{data.location.workspace ? <><p className="mb-1 text-xs text-muted-foreground">Workspace on the installation host</p><CopyText text={data.location.workspace} label="workspace path" /></> : <p role="alert" className="text-sm">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">Edit files, consolidate and commit</summary>
{data.location.workspace ? <div className="mt-3"><p className="mb-1 text-xs text-muted-foreground">Workspace on the installation host</p><CopyText text={data.location.workspace} label="workspace path" /></div> : <p role="alert" className="mt-3 text-sm">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>Edit the files under <code>evidence/curated/&lt;kind&gt;/</code> with your preferred editor. Keep existing IDs. Remove a unit's file to delete it; keep the curated directory.</li>
<li>Check Git status and the diff. Saving a file does not update recall.
@@ -127,7 +127,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">{states[item.status]} · {item.purposes.join(", ")}</p><h2 className="mt-1 font-serif text-xl font-semibold">{item.title}</h2></header>
<header><p className="text-xs text-muted-foreground">{states[item.status]} · {item.purposes.join(", ")}</p><h2 className="mt-1 font-sans text-xl font-semibold">{item.title}</h2></header>
{file && <CopyText text={file} label="Evidence file path" />}
{item.status !== "active" && <p className="text-sm text-muted-foreground">{["invalid", "removed"].includes(item.status) ? "The file is invalid or removed. Its last active content is shown here." : "This is the file version awaiting consolidation."} 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">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">{name}</span>: {values.join(", ")}</p>)}</section>}
+4 -4
View File
@@ -16,10 +16,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="Evidence sources" className="rounded-md border p-4">
return <section aria-label="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">Sources and imports</h2>
<p className="mt-1 max-w-[75ch] text-sm text-muted-foreground">Place new Markdown drafts in the workspace’s <code>evidence/incoming/</code> 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">Sources and imports</h2>
<p className="mt-1 max-w-[75ch] text-sm text-muted-foreground">Import sources, then review proposed changes before activation.</p><details className="mt-2 text-sm"><summary className="cursor-pointer font-medium">Which sources are imported?</summary><p className="mt-2 max-w-[75ch] text-muted-foreground">Place new Markdown drafts in <code>evidence/incoming/</code>. 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"});}}>Import or refresh sources</Button>
</div>
@@ -60,7 +60,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">{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 || "(empty value)"}</dt><dd>{v}</dd></div>)}</dl>}</section>)}
<p className="mt-3 text-xs text-muted-foreground">{unit.provenance.kind === "manual" ? `Manual declaration by ${unit.provenance.declared_by}. Original excerpts:` : "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">{r.message}</p>)}
</article>;
}
+6 -5
View File
@@ -67,6 +67,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 });
@@ -146,7 +147,7 @@ function MemoryWorkspace({ workspace, onDirtyChange, onBusyChange }: {
{list.isError && <div role="alert" className="p-6"><p>{apiErrorMessage(list.error)}</p><Button variant="outline" onClick={() => void list.refetch()}>Retry loading</Button></div>}
{list.data && <>
<p className="px-6 py-3 text-xs text-muted-foreground">{list.data.total} {list.data.total === 1 ? "card" : "cards"}</p>
{list.data.items.length === 0 && <p className="px-6 py-8 text-sm text-muted-foreground">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 ? "No cards match these filters. Adjust the filters or create a card." : "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" : ""}`}>
@@ -154,17 +155,17 @@ function MemoryWorkspace({ workspace, onDirtyChange, onBusyChange }: {
<span className="mt-1 block text-xs text-muted-foreground">{families[card.family]} · {card.origin === "manual" ? "Manual" : "Workflow"}</span>
<span className="mt-2 block text-xs">{card.indexed ? "Index up to date" : "Index update pending"}</span>
</button></li>)}</ul>
<nav aria-label="Memory pagination" className="flex items-center justify-between gap-2 p-4">
{list.data.total > 25 && <nav aria-label="Memory pagination" className="flex items-center justify-between gap-2 p-4">
<Button variant="outline" disabled={page <= 1} onClick={() => setPage(p => p - 1)}>Previous</Button>
<span className="text-xs">Page {page} 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)}>Next</Button>
</nav>
</nav>}
</>}
</section>
<section aria-label="Memory detail" className="thot-administration-detail">
{!draft && !selected && <p className="py-8 text-sm text-muted-foreground">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">{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">{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" ? "Content" : key === "sql" ? "Approved SQL" : key}</dt>
@@ -185,7 +186,7 @@ function MemoryWorkspace({ workspace, onDirtyChange, onBusyChange }: {
<Button variant="outline" disabled={busy} onClick={() => setDeleteConfirm(false)}>Keep card</Button></div></div>}
</div>}
{draft && <form onSubmit={save} className="space-y-5">
<h2 className="font-serif text-xl font-semibold">{selected ? "Edit card" : "New Memory card"}</h2>
<h2 className="font-sans text-xl font-semibold">{selected ? "Edit card" : "New Memory card"}</h2>
<fieldset disabled={busy} className="space-y-4">
<label className="grid gap-1 text-sm font-medium">Family<select aria-label="Family" className={control} value={draft.family} onChange={e => {
update("family", e.target.value as MemoryFamily); if (e.target.value !== "solved_question") update("sql", "");
+1 -1
View File
@@ -48,7 +48,7 @@ function MarkdownBody({ entry }: { entry: ActivityEntry }) {
function ActivityRow({ entry }: { entry: ActivityEntry }) {
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">{ACTIVITY_LABELS[entry.kind]}</span>
{entry.phase && <span aria-label={`Phase ${entry.phase}`}>{entry.phase}</span>}
</div>
+2 -2
View File
@@ -74,14 +74,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">
Owner: {s.author ?? "Unknown"}
</span>
)}
+1 -1
View File
@@ -75,7 +75,7 @@ test("shows every session model exposed by Core", async () => {
] })));
renderManagement();
const catalog = await screen.findByRole("region", { name: "Session models" });
const catalog = await screen.findByRole("region", { name: "Available models" });
expect(catalog).toHaveTextContent("GLM 5.3");
expect(catalog).toHaveTextContent("DeepSeek V4 Pro");
expect(catalog).toHaveTextContent("DeepSeek V4 Flash");
+23 -23
View File
@@ -48,7 +48,7 @@ function ReadinessRail({ ready, configured, credentials, smokeState }: {
}
function RailItem({ label, value, state }: { label: string; value: string; state: "ready" | "attention" | "idle" }) {
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">{label}</p>
<p className={`mt-1 flex items-start gap-1.5 text-sm font-semibold ${iconClass}`}>
@@ -139,12 +139,12 @@ function PiInstructionSteps({ details }: { details: PiPlatformDetails }) {
<p className="mt-1 text-muted-foreground">Using {details.terminal}, 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> 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">The relevant layout is:</p>
<PiCodeBlock className="mt-1">project-root/
<PiCodeBlock className="mt-1">{`project-root/
├── compose.yaml
├── deploy/
│ └── &lt;installation-id&gt;/
│ └── <installation-id>/
│ └── thothii-installation.yaml
└── docker/</PiCodeBlock>
└── docker/`}</PiCodeBlock>
<p className="mt-2 text-muted-foreground">The <code>deploy/</code> directory contains the selected installation descriptor and profile files. You do not need to create or manage a <code>bin/</code> directory: <code>tht</code> is the installed host CLI. If discovery finds multiple descriptors, pass the intended one explicitly with <code>--installation &lt;absolute-path&gt;/thothii-installation.yaml</code>. If <code>tht</code> is not on your <code>PATH</code>, install it using the installation guide.</p>
</li>
<li>
@@ -179,13 +179,13 @@ function PiInstructionSteps({ details }: { details: PiPlatformDetails }) {
<h4 className="font-semibold text-foreground">Update the Pi version</h4>
<p className="mt-1 text-muted-foreground">Use <code>tht pi update</code> 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>. Add <code>--version &lt;VERSION&gt;</code> when an explicitly reviewed version is required. The digest-pinned <code>--source pull</code> 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">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">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">Diagnose and recover a failed operation</h4>
<p className="mt-1 text-muted-foreground">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">Configuration</dt><dd>Invalid YAML, an unknown model default, or an incompatible use/adapter combination: fix the reported <code>modelCatalog</code> field, then reload. Do not repair generated JSON directly.</dd>
<dt className="font-semibold text-foreground">Credentials</dt><dd>Missing or unreadable credential: correct the protected credential file selected during setup and its permissions, without printing the file.</dd>
@@ -194,11 +194,11 @@ function PiInstructionSteps({ details }: { details: PiPlatformDetails }) {
</dl>
<p className="mt-2 text-muted-foreground">If maintenance is inactive, no recovery is needed: correct the cause and retry the original command.</p>
<p className="mt-2 text-muted-foreground">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">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">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>;
}
@@ -222,9 +222,9 @@ function PiPlatformInstructions({ hostPlatform }: { hostPlatform?: PiPlatform })
}
return (
<section aria-label="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">Using the host terminal:</p>
<div role="tablist" aria-label="Pi host platform" className="grid grid-cols-3 gap-1 rounded-md bg-muted p-1">
<section aria-label="Pi operator workflow" className="thot-pi-guide grid gap-5 text-base leading-relaxed">
<p className="text-muted-foreground">Run these commands on the installation host. Choose its operating system.</p>
<div role="tablist" aria-label="Pi host platform" className="thot-workspace-area-tabs">
{piPlatforms.map((platform) => (
<button
key={platform.id}
@@ -235,7 +235,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);
@@ -359,7 +359,7 @@ export function PiManagement({ open, onClose, page = false }: { open: boolean; o
<p className="thot-label">Bundled runtime</p>
<p className="mt-1 font-mono text-sm font-semibold">Pi {statusQuery.data.version ?? "version unavailable"}</p>
</div>
<p role="status" aria-label="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="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 ? "Ready" : "Needs attention"}
</p>
</div>
@@ -371,7 +371,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">{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">{statusQuery.data.message}</p>}
{feedback && <p role="status" aria-label="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))]" />}
{feedback.message}
@@ -379,14 +379,14 @@ export function PiManagement({ open, onClose, page = false }: { open: boolean; o
<section aria-label="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">Installation catalog default</h3><p className="mt-1 text-sm text-muted-foreground">This value is read from <code>modelCatalog.defaults.interaction</code>. 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">Installation catalog default</h3><p className="mt-1 text-sm text-muted-foreground">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">Change the installation default</summary><p className="mt-2 text-muted-foreground">Update <code>modelCatalog.defaults.interaction</code> 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">Provider</p><p className="mt-1 font-mono font-semibold">{statusQuery.data.config.provider}</p></div>
<div><p className="thot-label">Model</p><p className="mt-1 font-mono font-semibold">{statusQuery.data.config.model}</p></div>
<div><p className="thot-label">Reasoning</p><p className="mt-1 font-mono font-semibold">{statusQuery.data.config.reasoning}</p></div>
<div><p className="thot-label">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 />}Test catalog default</Button>
@@ -394,7 +394,7 @@ export function PiManagement({ open, onClose, page = false }: { open: boolean; o
</>
) : (
<div className="mt-4">
<div role="alert" aria-label="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="Pi configuration incomplete" className="rounded-md border border-warning/30 bg-warning/10 p-4 text-sm text-warning">
<p className="font-semibold">The catalog default is unavailable.</p>
<p className="mt-1">Fix <code>modelCatalog.defaults.interaction</code> in the installation YAML, reload Pi, then retry this panel.</p>
</div>
@@ -406,14 +406,14 @@ export function PiManagement({ open, onClose, page = false }: { open: boolean; o
)}
</section>
<section aria-label="Session models" className="border-t border-border/70 pt-5">
<section aria-label="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">Session models</h3>
<p className="mt-1 text-sm text-muted-foreground">Models currently available for interactive sessions in this installation.</p>
<h3 className="font-heading text-sm font-semibold">Available models</h3>
<p className="mt-1 text-sm text-muted-foreground">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} available</span>
<span className="text-xs tabular-nums text-muted-foreground">{modelsQuery.data.models.length} available</span>
)}
</div>
{modelsQuery.isLoading ? (
+9 -8
View File
@@ -78,9 +78,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" ? "Approved" : "Declined"}
@@ -94,7 +94,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>
)}
@@ -119,7 +119,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 ?? "")}`}>
{(d.type ?? "decision").replace(/_/g, " ")}
</span>
{d.subject && (
@@ -145,7 +145,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";
@@ -161,10 +161,11 @@ export function SessionDocumentsPanel({ session, onClose, onResume, desktopSplit
return (
<aside
aria-label="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]",
@@ -172,7 +173,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>
@@ -204,7 +205,7 @@ export function SessionDocumentsPanel({ session, onClose, onResume, desktopSplit
{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>
)}
+2 -2
View File
@@ -276,7 +276,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="Input, cached, and output tokens"
>
{formatTokenUsage(tokenUsage)}
@@ -330,7 +330,7 @@ function FooterSelect({
onChange={(e) => onChange(e.target.value)}
{...rest}
className={[
"max-w-[10rem] cursor-pointer truncate rounded-lg border border-border bg-card px-1.5 py-0.5 text-[0.7rem] text-foreground/80 outline-none transition-colors hover:bg-muted focus:border-primary/40 focus:ring-2 focus:ring-ring/15",
"max-w-[10rem] cursor-pointer truncate rounded-lg border border-border bg-card px-1.5 py-0.5 text-xs text-foreground/80 outline-none transition-colors hover:bg-muted focus:border-primary/40 focus:ring-2 focus:ring-ring/15",
className,
].filter(Boolean).join(" ")}
>
+7 -7
View File
@@ -94,19 +94,19 @@
.thot-work-area-panel__eyebrow {
margin: 0;
color: inherit;
font-family: var(--font-mono);
font-size: 0.6875rem;
font-weight: 700;
letter-spacing: 0.08em;
font-family: var(--font-sans);
font-size: var(--text-meta);
font-weight: 600;
letter-spacing: normal;
line-height: 1.35;
text-transform: uppercase;
text-transform: none;
}
.thot-work-area-panel__title {
margin: 0.25rem 0 0;
color: inherit;
font-family: var(--font-heading);
font-size: 1.35rem;
font-size: var(--text-section);
font-weight: 600;
letter-spacing: -0.025em;
line-height: 1.18;
@@ -116,7 +116,7 @@
max-width: 68ch;
margin: 0.45rem 0 0;
color: inherit;
font-size: 0.875rem;
font-size: var(--text-control);
line-height: 1.55;
}
+3 -3
View File
@@ -36,9 +36,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",
};
@@ -108,7 +108,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(" ")}
+3 -3
View File
@@ -3,16 +3,16 @@
.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-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[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-explanation { max-width: none; margin: 0 0 20px; font-size: var(--text-control); 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; }
+1 -1
View File
@@ -14,7 +14,7 @@ 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>WORKING CONTEXT</small><strong>{context.workspace?.displayName ?? "Choose workspace"}<span aria-hidden> / </span>{context.model?.name ?? "Choose model"}</strong></span>
<span className="thot-context-summary"><small>Working context</small><strong>{context.workspace?.displayName ?? "Choose workspace"}<span aria-hidden> / </span>{context.model?.name ?? "Choose model"}</strong></span>
<span className="thot-context-action">{locked && <LockKeyhole size={15} aria-hidden />}<span>{locked ? "Locked" : open ? "Close" : "Change context"}</span><ChevronDown size={16} aria-hidden /></span>
</button>
<div id="thoth-working-context" hidden={!open} className="thot-context-body">
+2 -2
View File
@@ -294,7 +294,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" }));
@@ -618,7 +618,7 @@ test("renders binding_ok as a green connection success", async () => {
await user.click(within(connectionCard).getByRole("button", { name: "Test workspace connections" }));
const connectionStatus = await within(connectionCard).findByRole("status");
expect(connectionStatus).toHaveTextContent("binding_ok: Installation bindings and diagnostics succeeded.");
expect(connectionStatus).toHaveClass("text-emerald-700");
expect(connectionStatus).toHaveClass("text-success");
expect(within(connectionCard).queryByRole("alert")).not.toBeInTheDocument();
});
+16 -16
View File
@@ -444,7 +444,7 @@ export function WorkspaceManager({
>
<ArrowLeft />{page ? "Repository overview" : "Back to Level 1"}
</Button>
<p className="mb-2 px-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">Available workspaces</p>
<p className="mb-2 px-2 text-xs font-semibold text-muted-foreground">Available workspaces</p>
<div className="min-h-0 flex-1 overflow-y-auto">
{workspacesQuery.isLoading && <p className="p-2 text-xs text-muted-foreground">Loading workspaces…</p>}
{workspacesQuery.isError && (
@@ -461,7 +461,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">{stateLabel(workspace.configurationState)}</span>
<span className="mt-1 block text-xs text-muted-foreground">{stateLabel(workspace.configurationState)}</span>
</button>
))}
</div>
@@ -477,10 +477,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" />{diagnostic}
<AlertCircle className="mt-0.5 size-4 shrink-0 text-warning" />{diagnostic}
</p>
))}
</div>
@@ -606,15 +606,15 @@ export function WorkspaceManager({
<h4 className="font-heading font-semibold">Validate workspace source</h4>
<p className="mt-1 min-h-12 text-sm leading-5 text-muted-foreground">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" />{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" />{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" />{diagnostic}
<AlertCircle className="mt-0.5 size-4 shrink-0 text-warning" />{diagnostic}
</p>
))}
</div>
@@ -627,15 +627,15 @@ export function WorkspaceManager({
<h4 className="font-heading font-semibold">Test workspace connections</h4>
<p className="mt-1 min-h-12 text-sm leading-5 text-muted-foreground">Verifies the database configured in Database Management, plus this workspace&apos;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" />{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" />{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" />{diagnostic}
<AlertCircle className="mt-0.5 size-4 shrink-0 text-warning" />{diagnostic}
</p>
))}
</div>
@@ -653,14 +653,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">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 ? "Passed" : "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 ? message : `${field} — ${message}`}
</p>
))}
@@ -683,7 +683,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">{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 ? "Configured" : "Not configured"}
</span>
</div>
@@ -36,11 +36,11 @@ const INITIAL_PROGRESS: PreprocessingProgress = {
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";
}
}
@@ -58,7 +58,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">
{PROGRESS_LABELS[progress.stage]}
</span>
@@ -82,9 +82,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(" ")}
/>
@@ -108,10 +108,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">
@@ -246,7 +246,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">
Preprocessing
</p>
{confirmingClear ? (
@@ -256,13 +256,13 @@ 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">Clear derived data?</strong>
<small className="text-[8px] leading-tight text-muted-foreground">
<strong className="text-xs font-semibold text-foreground">Clear derived data?</strong>
<small className="text-xs leading-tight text-muted-foreground">
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" />
@@ -270,17 +270,17 @@ export function WorkspacePreprocessingControl({
<span><strong className="block text-foreground">Clear</strong><small className="text-muted-foreground">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">Keep</strong><small className="text-muted-foreground">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)}>
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 ? "Clearing" : "Clear derived data"}
</Button>
@@ -293,8 +293,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">{title}</strong>
{state !== "running" && <span className="text-[9px] leading-tight text-muted-foreground" title={detail}>{detail}</span>}
<strong className="text-xs font-semibold leading-tight text-foreground">{title}</strong>
{state !== "running" && <span className="text-xs leading-tight text-muted-foreground" title={detail}>{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">
@@ -302,7 +302,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 ? "Database management permission is required" : undefined}
onClick={() => setConfirmingClear(true)}
@@ -313,7 +313,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 ? "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,7 +1,7 @@
import type { ReactNode } from "react";
import "./AdministrationPage.css";
export function AdministrationHeader({ title, description, actions, eyebrow = "Administration / Workbench" }: {
export function AdministrationHeader({ title, description, actions, eyebrow = "Administration" }: {
title: ReactNode; description?: ReactNode; actions?: ReactNode; eyebrow?: ReactNode;
}) {
return <header className="thot-administration-header">
@@ -371,20 +371,20 @@ export function CatalogSyncDrawer({
key={phase}
aria-current={current ? "step" : undefined}
aria-label={`${label}, ${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" />
)}
@@ -398,16 +398,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="Destructive changes">
<section className="mt-5 rounded-md border border-warning/50 bg-warning/8 p-4" aria-label="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">Confirm {deletionCount} removals</h3>
<p className="mt-1 text-sm text-muted-foreground">The database will be scanned again before anything is removed.</p>
@@ -453,7 +453,7 @@ export function CatalogSyncDrawer({
className="thot-catalog-drawer__event-log max-h-64 overflow-y-auto rounded-md bg-zinc-950 p-3 font-mono text-xs leading-5 text-zinc-200"
>
{events.length === 0 ? <p className="text-zinc-500">Waiting for events…</p> : events.map((event) => (
<p key={event.sequence} className={event.level === "error" ? "text-red-300" : event.level === "warning" ? "text-amber-300" : undefined}>
<p key={event.sequence} className={event.level === "error" ? "text-red-300" : event.level === "warning" ? "text-warning" : undefined}>
<span className="mr-2 text-zinc-500">{new Date(event.createdAt).toLocaleTimeString()}</span>{event.message}
</p>
))}
@@ -516,8 +516,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">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">Newer column metadata is available.</p><p className="mt-1 text-muted-foreground">Your draft is preserved until you reload.</p><div className="mt-3 flex gap-2"><Button type="button" variant="outline" onClick={() => void reloadColumn()}>Reload latest</Button><Button type="button" variant="ghost" onClick={() => setStaleBannerOpen(false)}>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>Reload the latest value before this metadata can be saved.</span><Button type="button" variant="outline" onClick={() => void reloadColumn()}>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">Newer column metadata is available.</p><p className="mt-1 text-muted-foreground">Your draft is preserved until you reload.</p><div className="mt-3 flex gap-2"><Button type="button" variant="outline" onClick={() => void reloadColumn()}>Reload latest</Button><Button type="button" variant="ghost" onClick={() => setStaleBannerOpen(false)}>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>Reload the latest value before this metadata can be saved.</span><Button type="button" variant="outline" onClick={() => void reloadColumn()}>Reload latest</Button></div>
) : null}
<ColumnMetadataFieldMatrix
column={active}
@@ -652,8 +652,8 @@ export function DatabaseColumns({
<p className="thot-label">{table.name} · 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">Newer column metadata is available.</p><p className="mt-1 text-muted-foreground">Your draft is preserved until you reload.</p><div className="mt-3 flex gap-2"><Button type="button" variant="outline" onClick={() => void reloadColumn()}>Reload latest</Button><Button type="button" variant="ghost" onClick={() => setStaleBannerOpen(false)}>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>Reload the latest value before this metadata can be saved.</span><Button type="button" variant="outline" onClick={() => void reloadColumn()}>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">Newer column metadata is available.</p><p className="mt-1 text-muted-foreground">Your draft is preserved until you reload.</p><div className="mt-3 flex gap-2"><Button type="button" variant="outline" onClick={() => void reloadColumn()}>Reload latest</Button><Button type="button" variant="ghost" onClick={() => setStaleBannerOpen(false)}>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>Reload the latest value before this metadata can be saved.</span><Button type="button" variant="outline" onClick={() => void reloadColumn()}>Reload latest</Button></div>
) : null}
<ColumnMetadataFieldMatrix
column={active}
@@ -220,7 +220,7 @@ export function DatabaseForm({
{stale ? (
staleBannerOpen ? (
<div className="rounded-md border border-amber-300/70 bg-amber-50 p-4 text-sm text-amber-950 dark:border-amber-700 dark:bg-amber-950/30 dark:text-amber-100" role="alert">
<div className="rounded-md border border-warning/70 bg-warning p-4 text-sm text-warning dark:border-warning dark:bg-warning/30 text-warning" role="alert">
<p className="font-semibold">A newer database configuration is available.</p>
<p className="mt-1 leading-5">Reload the latest values before saving, testing, or deleting. Your draft is still intact.</p>
<div className="mt-3 flex flex-wrap gap-2">
@@ -233,14 +233,14 @@ export function DatabaseForm({
</div>
</div>
) : (
<div className="rounded-md border border-amber-300/70 bg-amber-50 px-4 py-3 text-sm text-amber-950 dark:border-amber-700 dark:bg-amber-950/30 dark:text-amber-100" role="status">
<div className="rounded-md border border-warning/70 bg-warning px-4 py-3 text-sm text-warning dark:border-warning dark:bg-warning/30 text-warning" role="status">
Reload the latest values before this configuration can be changed.
</div>
)
) : null}
{partialSecretFailure ? (
<div className="rounded-md border border-amber-300/70 bg-amber-50 p-4 text-sm text-amber-950 dark:border-amber-700 dark:bg-amber-950/30 dark:text-amber-100" role="alert">
<div className="rounded-md border border-warning/70 bg-warning p-4 text-sm text-warning dark:border-warning dark:bg-warning/30 text-warning" role="alert">
<p className="font-semibold">Database configuration saved; secret update could not be confirmed.</p>
<p className="mt-1 leading-5">{partialSecretFailure} The values you entered are retained in this form.</p>
<Button type="button" size="sm" variant="outline" className="mt-3" disabled={busy || stale} onClick={onRetrySecrets}>
@@ -102,9 +102,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",
};
@@ -125,7 +125,7 @@ function StateCell({
<span className={`size-1.5 shrink-0 rounded-full ${stateToneClass[tone]}`} aria-hidden="true" />
<span className="truncate">{label}</span>
</span>
<span className={`truncate pl-3 text-[11px] ${detailClassName}`}>{detail}</span>
<span className={`truncate pl-3 text-xs ${detailClassName}`}>{detail}</span>
</div>
);
}
@@ -621,7 +621,7 @@ export function DatabaseGrid({
<p
role="note"
aria-label="Metadata generation source data disclosure"
className="mt-1 text-xs font-medium text-amber-800 dark:text-amber-300"
className="mt-1 text-xs font-medium text-warning"
>
When available, up to five real source rows and five representative values from
columns not marked sensitive are sent to the selected provider.
@@ -586,7 +586,7 @@ export function DatabaseRelationships({
<dt className="thot-label">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">Source</th><th className="px-3 py-2">Target</th></tr></thead>
<thead className="bg-muted/60 font-sans text-muted-foreground"><tr><th className="px-3 py-2">Source</th><th className="px-3 py-2">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>
@@ -599,12 +599,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">A newer table description is available.</p>
<p className="mt-1 text-muted-foreground">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()}>Reload latest</Button><Button type="button" variant="ghost" onClick={() => setStaleBannerOpen(false)}>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>Reload the latest value before this description can be saved.</span><Button type="button" variant="outline" onClick={() => void reloadTable()}>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>Reload the latest value before this description can be saved.</span><Button type="button" variant="outline" onClick={() => void reloadTable()}>Reload latest</Button></div>
) : null}
<div className="mt-5 grid gap-4">
<label className="grid gap-1.5 text-sm font-semibold">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>
@@ -650,12 +650,12 @@ export function DatabaseTables({
<p className="text-sm text-muted-foreground">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">A newer table description is available.</p>
<p className="mt-1 text-muted-foreground">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()}>Reload latest</Button><Button type="button" variant="ghost" onClick={() => setStaleBannerOpen(false)}>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>Reload the latest value before this description can be saved.</span><Button type="button" variant="outline" onClick={() => void reloadTable()}>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>Reload the latest value before this description can be saved.</span><Button type="button" variant="outline" onClick={() => void reloadTable()}>Reload latest</Button></div>
) : null}
<div className="mt-5 grid gap-4">
<label className="grid gap-1.5 text-sm font-semibold">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>
@@ -327,7 +327,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"
>
Unlock interrupts apparently stale work and marks the run interrupted. A live worker will prevent it.
</p>
@@ -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">
{label}
</th>
))}
@@ -458,7 +458,7 @@ export function DescriptionGenerationDrawer({
className="thot-catalog-drawer__event-log overflow-y-auto rounded-md bg-zinc-950 p-3 font-mono text-xs leading-5 text-zinc-200"
>
{events.length === 0 ? <p className="text-zinc-500">Waiting for events…</p> : events.map((event) => (
<p key={event.sequence} className={event.level === "error" ? "text-red-300" : event.level === "warning" ? "text-amber-300" : undefined}>
<p key={event.sequence} className={event.level === "error" ? "text-red-300" : event.level === "warning" ? "text-warning" : undefined}>
<span className="mr-2 text-zinc-500">{new Date(event.createdAt).toLocaleTimeString()}</span>{event.message}
</p>
))}
@@ -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,
@@ -54,12 +54,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 +75,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 +85,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;
}
@@ -191,15 +191,15 @@
.thot-fleet-ledger__status-label {
grid-column: 1 / -1;
font-size: 0.625rem;
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 +208,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;
@@ -224,18 +224,17 @@
.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 +245,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;
}
@@ -280,7 +279,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 +325,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 +345,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 +391,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 +412,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 +429,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 +437,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;
}
@@ -628,10 +627,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 +646,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 +672,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 +701,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 +765,7 @@
}
.thot-fleet-ledger__drawer-title {
font-size: 1.35rem;
font-size: var(--text-section);
line-height: 1.18;
}
@@ -949,19 +949,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;
@@ -180,7 +180,7 @@ export interface FleetLedgerKpiStripProps extends WithClassName {
}
export function FleetLedgerKpiStrip({
title = "Fleet summary",
title = "Catalog summary",
items,
className,
}: FleetLedgerKpiStripProps) {
@@ -211,7 +211,7 @@ export function SensitiveDataReviewDrawer({
: "no sensitive match"}. Coverage: {suggestion.coverage.replace("_", " ")}. 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-warning" : "bg-muted text-muted-foreground"}`}>
{changedFromCurrent ? "Change" : "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">
{label}
</th>
))}
@@ -320,7 +320,7 @@ export function SensitivityAnalysisHistoryDrawer({
className="thot-catalog-drawer__event-log overflow-y-auto rounded-md bg-zinc-950 p-3 font-mono text-xs leading-5 text-zinc-200"
>
{events.length === 0 ? <p className="text-zinc-500">Waiting for events…</p> : events.map((event) => (
<p key={event.sequence} className={event.level === "error" ? "text-red-300" : event.level === "warning" ? "text-amber-300" : undefined}>
<p key={event.sequence} className={event.level === "error" ? "text-red-300" : event.level === "warning" ? "text-warning" : undefined}>
<span className="mr-2 text-zinc-500">{new Date(event.createdAt).toLocaleTimeString()}</span>{event.message}
</p>
))}
+1 -1
View File
@@ -5,7 +5,7 @@ import type { CtePlanCte, CtePlanFilter, CtePlanV2 } from "./artifactV2";
const CHIP =
"min-w-0 max-w-full whitespace-pre-wrap [overflow-wrap:anywhere] rounded-md bg-muted px-1.5 py-0.5 font-mono text-xs text-foreground/90";
const FIELD_LABEL = "text-[0.65rem] font-semibold uppercase tracking-[0.08em] text-muted-foreground";
const FIELD_LABEL = "text-xs font-semibold text-muted-foreground";
function Chip({ children }: { children: ReactNode }) {
return <span className={CHIP}>{children}</span>;
+1 -1
View File
@@ -146,7 +146,7 @@ export function SchemaLinkingViewer({ linking }: { linking: SchemaLinking }) {
</div>
{oversized && (
<p className="text-sm text-amber-600">
<p className="text-sm text-warning">
Too many nodes to display in the graph. Use the table instead.
</p>
)}
+1 -1
View File
@@ -80,7 +80,7 @@ function Block({ block }: { block: SqlBlock }) {
className="flex shrink-0 items-center gap-1.5 border-l border-border/70 px-2.5 text-xs text-muted-foreground transition-colors hover:bg-muted/40 hover:text-foreground"
>
{copyState === "copied" ? (
<Check className="size-3.5 text-[oklch(0.47_0.12_165)]" aria-hidden="true" />
<Check className="size-3.5 text-success" aria-hidden="true" />
) : copyState === "failed" ? (
<TriangleAlert className="size-3.5 text-destructive" aria-hidden="true" />
) : (
+1 -1
View File
@@ -5,7 +5,7 @@ export function FallbackWidget({ descriptor, onRespond }: WidgetProps) {
const [text, setText] = useState("");
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">Unsupported widget (kind: {descriptor.widget}) — respond manually</p>
<p className="text-sm text-warning">Unsupported widget (kind: {descriptor.widget}) — respond manually</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 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={() => onRespond({ id: descriptor.id, control: "freetext", text })}>Send</button>
+1 -1
View File
@@ -9,7 +9,7 @@ export function FreetextWidget({ descriptor, onRespond }: WidgetProps) {
return (
<div className="space-y-3 rounded-xl border border-border/70 bg-card p-4 shadow-sm">
{descriptor.title && (
<p className="text-[0.95rem] font-semibold leading-snug text-foreground">
<p className="text-base font-semibold leading-snug text-foreground">
{descriptor.title}
</p>
)}
+1 -1
View File
@@ -1,5 +1,5 @@
import type { WidgetProps } from "./types";
export function InfoWidget({ descriptor }: WidgetProps) {
const color = descriptor.level === "error" ? "text-red-600" : descriptor.level === "warning" ? "text-amber-600" : "text-foreground";
const color = descriptor.level === "error" ? "text-red-600" : descriptor.level === "warning" ? "text-warning" : "text-foreground";
return <p className={`text-sm leading-relaxed ${color}`}>{descriptor.text ?? descriptor.title}</p>;
}
+2 -2
View File
@@ -9,7 +9,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,7 +45,7 @@ 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">
<span className="text-xs font-medium text-muted-foreground">
Rationale
</span>
<p className="text-sm leading-relaxed text-muted-foreground">
+5 -5
View File
@@ -25,7 +25,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>
)}
@@ -66,8 +66,8 @@ 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">
<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">
Reason
</div>
<div className="thot-prose leading-relaxed [&>p:first-child]:mt-0 [&>p:last-child]:mb-0">
@@ -76,8 +76,8 @@ export function MultiselectWidget({ descriptor, onRespond }: WidgetProps) {
</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">
<div className="mt-3 border-l border-border/50 pl-3 text-xs text-muted-foreground">
<div className="mb-1 font-semibold">
Context question
</div>
<div className="thot-prose leading-relaxed [&>p:first-child]:mt-0 [&>p:last-child]:mb-0">
@@ -58,7 +58,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">
+2 -2
View File
@@ -8,7 +8,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>
)}
@@ -24,7 +24,7 @@ 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)]">
<span className="shrink-0 rounded-md bg-[oklch(var(--success)/0.16)] px-2 py-0.5 text-xs font-medium text-success">
Recommended
</span>
)}
+13
View File
@@ -1,6 +1,8 @@
import type { Config } from "tailwindcss";
export default {
// Match the token theme, including the portal, rather than the OS preference alone.
darkMode: ["selector", ':is(.dark, [data-bs-theme="dark"])'],
content: ["./index.html", "./src/**/*.{ts,tsx}"],
theme: {
extend: {
@@ -37,6 +39,8 @@ export default {
border: "oklch(var(--border) / <alpha-value>)",
input: "oklch(var(--input) / <alpha-value>)",
ring: "oklch(var(--ring) / <alpha-value>)",
success: "oklch(var(--success) / <alpha-value>)",
warning: "oklch(var(--warning) / <alpha-value>)",
sidebar: {
DEFAULT: "oklch(var(--sidebar) / <alpha-value>)",
foreground: "oklch(var(--sidebar-foreground) / <alpha-value>)",
@@ -53,6 +57,7 @@ export default {
md: "calc(var(--radius) - 2px)",
sm: "calc(var(--radius) - 4px)",
},
ringWidth: { 3: "3px" },
boxShadow: {
xs: "var(--shadow-xs)",
sm: "var(--shadow-sm)",
@@ -64,6 +69,14 @@ export default {
heading: ["var(--font-heading)", "sans-serif"],
mono: ["var(--font-mono)", "monospace"],
},
fontSize: {
xs: ["var(--text-meta)", "1.5"],
sm: ["var(--text-control)", "1.5"],
base: ["var(--text-body)", "1.5"],
lg: ["var(--text-section)", "1.4"],
xl: ["var(--text-section)", "1.4"],
"2xl": ["var(--text-page)", "1.3"],
},
},
},
plugins: [],