fix(ui): unify Memory and Evidence reading typography

This commit is contained in:
Codex
2026-09-13 17:07:37 +02:00
parent 3535fda958
commit 26c5605ff7
7 changed files with 188 additions and 24 deletions
+19
View File
@@ -351,6 +351,25 @@ default, hover, focus, active, disabled, loading, and error behavior where those
### Curated Evidence Documents ### Curated Evidence Documents
Memory and Evidence share the `thot-knowledge-reader` reading contract. Use locally
bundled Manrope with normal tracking for prose and labels, and these fixed roles:
- Card title: 24px, weight 600, line-height 1.3 (`thot-knowledge-title`).
- Field/section heading, including Scope and Provenance: 20px, weight 600,
line-height 1.4, 8px clearance below (`thot-knowledge-heading`).
- All narrative text, including scope, lists and provenance: 16px, weight 400,
line-height 1.65. Do not apply compact UI text sizes to these fields.
- Authored Markdown subheadings inside a field: 16px, weight 600, line-height 1.5,
24px above/8px below. They remain subordinate to the enclosing field heading;
their semantic heading levels and original content are preserved.
- Technical metadata labels/values: 14px/1.5, with weight 600 for labels.
Only code, paths and machine identifiers use the technical monospace family at
14px/1.65, identical for inline and fenced code (never compound `em` shrinkage).
Separate reading sections by 24px; keep the first Markdown block flush with its
field heading's 8px bottom gap. The same typography applies in light/dark and at
all responsive widths. Controls and archive indexes retain their compact UI roles.
Memory and Evidence detail readers use the entire available content width, without Memory and Evidence detail readers use the entire available content width, without
the ordinary 72–75ch prose cap. This is the owner's explicit reading-layout choice. the ordinary 72–75ch prose cap. This is the owner's explicit reading-layout choice.
Long unstructured paragraphs are split for display at existing sentence/semicolon Long unstructured paragraphs are split for display at existing sentence/semicolon
+13
View File
@@ -48,6 +48,19 @@ PSD Gitea push and production deployment are still pending.
## Current product shape ## Current product shape
### Shared Memory/Evidence typography — 2026-09-13
Both detail readers now share Manrope and fixed reading roles: 24px card title,
20px section headings, 16px/1.65 narrative text, and 14px metadata/code. Authored
Markdown subheadings remain subordinate to field headings; inline/fenced code no
longer shrinks cumulatively. Full-width layout, paragraph separation and isolated
FAKE examples are retained. All 762 frontend tests and 18 browser scenarios pass,
including computed typography checks at 390/1280/2400px; typecheck, i18n and Docker
build pass. Only Mac frontend was recreated: `87fca0dc5e19`, image `19f1704b6bb2`,
healthy. Core and data services are unchanged. Rollback image:
`thothii-frontend:before-knowledge-typography-20260913`. See
`docs/reports/2026-09-13-knowledge-typography.md`. No PSD/Omics deployment.
### Knowledge reading and isolated fake Memory examples — 2026-09-13 ### Knowledge reading and isolated fake Memory examples — 2026-09-13
Memory/Evidence details now use all available width, with display-only paragraph Memory/Evidence details now use all available width, with display-only paragraph
@@ -0,0 +1,52 @@
# Tipografia condivisa per Memory ed Evidence
## Modifica
I lettori mescolavano etichette da 14px, sezioni da 16px e Markdown con una
gerarchia indipendente. I blocchi di codice applicavano una riduzione relativa
anche al carattere già ridotto del contenitore.
La regola condivisa in `frontend/src/index.css`, documentata in `DESIGN.md`, usa:
- Manrope per titolo, sezioni, paragrafi, elenchi e provenienza.
- Titolo della scheda: 24px, peso 600, interlinea 1.3.
- Titoli dei campi: 20px, peso 600, interlinea 1.4, 8px prima del contenuto.
- Testo narrativo: 16px, peso 400, interlinea 1.65.
- Sottotitoli Markdown interni: 16px, peso 600; non competono con il titolo del
campo. I livelli semantici originali sono conservati.
- Metadati: 14px; codice, percorsi e identificatori: monospaziato a 14px, senza
riduzioni cumulative nei blocchi.
- Sezioni distanziate di 24px; paragrafi consecutivi distanziati di 20px.
L'uso della skill Impeccable ha guidato la gerarchia a ruoli fissi e la scelta di
un'unica famiglia, riutilizzando quella già distribuita dall'applicazione.
Rimane valida la richiesta esplicita di occupare tutta la larghezza disponibile:
nessun limite di riga in caratteri e nessuna riduzione dei font su mobile.
Controlli, indici degli archivi e altri lettori non ricevono questa nuova scala.
Documenti originali e schede salvate non sono stati riscritti; gli esempi FAKE
restano esclusi da salvataggio, indicizzazione e uso da parte del modello.
## Verifiche
- Typecheck frontend e build Docker completati.
- 762 test Vitest su 91 file e 18 scenari Playwright superati.
- Controlli CSS calcolati a 390, 1280 e 2400px: famiglia, dimensioni, pesi,
interlinea, margini, codice inline/fenced e sottotitoli Markdown da h1 a h6.
- Ispezione delle schermate Evidence in light e Memory in dark, anche mobile.
- Nessun overflow orizzontale; nessuna scrittura API negli scenari FAKE.
- Catalogo i18n: 1686 messaggi italiani, 1707 riferimenti statici verificati.
## Installazione locale
Ricreato solo il frontend del progetto Docker `thothii-18998cca7b0a`:
container `87fca0dc5e19`, immagine `19f1704b6bb2`, stato healthy.
Il CSS servito da `http://127.0.0.1:8080` contiene le nuove regole condivise.
Core, PostgreSQL, Qdrant ed embedding conservano i container precedenti.
Nessuna modifica alla configurazione full/en, a Omics o al server PSD.
Ripristino della precedente immagine locale:
```bash
docker image tag thothii-frontend:before-knowledge-typography-20260913 thothii-frontend:local
bash /private/tmp/thothii-memory-preview.sh up -d --no-deps --no-build --wait frontend
```
+40 -2
View File
@@ -109,7 +109,7 @@ for (const width of [390, 1280, 2400]) {
const writes = await fixtures(page); const writes = await fixtures(page);
const sample = memoryFormattingExamples[0]; const sample = memoryFormattingExamples[0];
const longEvidence = { ...evidence, applies_to: { concepts: ["Esempio simulato"], tables: ["demo_visuale.identificatore_lungo_per_la_verifica_della_formattazione"], columns: [] }, const longEvidence = { ...evidence, applies_to: { concepts: ["Esempio simulato"], tables: ["demo_visuale.identificatore_lungo_per_la_verifica_della_formattazione"], columns: [] },
payload: { rule: sample.detail }, provenance: { source_file: "source/demo.md", supporting_excerpts: ["**Esempio simulato** con `codice_inline` e un collegamento alla regola.\n\nSecondo paragrafo di provenienza."] } }; payload: { rule: sample.detail + "\n\n" + [1, 2, 3, 4, 5, 6].map(level => "#".repeat(level) + " Sottotitolo " + level + "\n\nTesto della sottosezione.").join("\n\n") + "\n\n```sql\nSELECT 1;\n```" }, provenance: { source_file: "source/demo.md", supporting_excerpts: ["**Esempio simulato** con `codice_inline` e un collegamento alla regola.\n\nSecondo paragrafo di provenienza."] } };
await page.route(`**/api/workspaces/${workspace}/evidence/*`, route => route.fulfill({ json: { ...evidencePage, item: longEvidence } })); await page.route(`**/api/workspaces/${workspace}/evidence/*`, route => route.fulfill({ json: { ...evidencePage, item: longEvidence } }));
await page.goto("/"); await page.goto("/");
await admin(page, "Evidence"); await admin(page, "Evidence");
@@ -120,6 +120,26 @@ for (const width of [390, 1280, 2400]) {
const rule = article.locator(".thot-knowledge-prose").first(); const rule = article.locator(".thot-knowledge-prose").first();
await expect(rule.locator(":scope > p")).not.toHaveCount(1); await expect(rule.locator(":scope > p")).not.toHaveCount(1);
await expect(rule.locator("p").first()).toHaveCSS("max-width", "none"); await expect(rule.locator("p").first()).toHaveCSS("max-width", "none");
await expect(article.locator(".thot-knowledge-title")).toHaveCSS("font-size", "24px");
for (const heading of await article.locator(".thot-knowledge-heading").all()) {
await expect(heading).toHaveCSS("font-size", "20px");
await expect(heading).toHaveCSS("font-weight", "600");
await expect(heading).toHaveCSS("margin-bottom", "8px");
}
const bodyFamily = await rule.evaluate(el => getComputedStyle(el).fontFamily);
expect(bodyFamily).toContain("Manrope");
for (const paragraph of await article.locator("section > p:not(.thot-knowledge-meta), .thot-knowledge-prose p").all()) {
await expect(paragraph).toHaveCSS("font-size", "16px");
await expect(paragraph).toHaveCSS("line-height", "26.4px");
await expect(paragraph).toHaveCSS("font-family", bodyFamily);
}
for (const heading of await rule.locator("h1,h2,h3,h4,h5,h6").all()) {
await expect(heading).toHaveCSS("font-size", "16px");
await expect(heading).toHaveCSS("font-weight", "600");
await expect(heading).toHaveCSS("font-family", bodyFamily);
}
await expect(rule.locator("pre code")).toHaveCSS("font-size", "14px");
await expect(rule.locator("p code").first()).toHaveCSS("font-size", "14px");
expect(await article.evaluate(el => { expect(await article.evaluate(el => {
const parent = el.parentElement!; const parent = el.parentElement!;
const css = getComputedStyle(parent); const css = getComputedStyle(parent);
@@ -129,7 +149,7 @@ for (const width of [390, 1280, 2400]) {
await expect(copy).toHaveText(""); await expect(copy).toHaveText("");
await expect(copy.locator("svg")).toHaveCount(1); await expect(copy.locator("svg")).toHaveCount(1);
await expect(article.locator("strong").filter({ hasText: "Esempio simulato" })).toBeVisible(); await expect(article.locator("strong").filter({ hasText: "Esempio simulato" })).toBeVisible();
await article.locator("h2").scrollIntoViewIfNeeded(); await article.locator(".thot-knowledge-title").scrollIntoViewIfNeeded();
await page.screenshot({ path: testInfo.outputPath("evidence-readable.png"), animations: "disabled" }); await page.screenshot({ path: testInfo.outputPath("evidence-readable.png"), animations: "disabled" });
expect((await inspect(page)).overflow).toBe(0); expect((await inspect(page)).overflow).toBe(0);
@@ -141,6 +161,16 @@ for (const width of [390, 1280, 2400]) {
await expect(content.locator(":scope > p")).not.toHaveCount(1); await expect(content.locator(":scope > p")).not.toHaveCount(1);
await expect(content.locator("p").first()).toHaveCSS("max-width", "none"); await expect(content.locator("p").first()).toHaveCSS("max-width", "none");
await expect(content.locator("p").nth(1)).toHaveCSS("margin-top", "20px"); await expect(content.locator("p").nth(1)).toHaveCSS("margin-top", "20px");
await expect(memoryDetail.locator(".thot-knowledge-title")).toHaveCSS("font-size", "24px");
for (const heading of await memoryDetail.locator(".thot-knowledge-heading").all()) {
await expect(heading).toHaveCSS("font-size", "20px");
await expect(heading).toHaveCSS("font-weight", "600");
await expect(heading).toHaveCSS("font-family", bodyFamily);
await expect(heading).toHaveCSS("margin-bottom", "8px");
}
await expect(content.locator("p").first()).toHaveCSS("font-size", "16px");
await expect(content.locator("p").first()).toHaveCSS("line-height", "26.4px");
await expect(content.locator("p").first()).toHaveCSS("font-family", bodyFamily);
expect(await content.evaluate(el => Math.abs(el.getBoundingClientRect().width - el.parentElement!.clientWidth))).toBeLessThan(2); expect(await content.evaluate(el => Math.abs(el.getBoundingClientRect().width - el.parentElement!.clientWidth))).toBeLessThan(2);
await memoryDetail.getByRole("heading", { name: sample.subject, exact: true }).scrollIntoViewIfNeeded(); await memoryDetail.getByRole("heading", { name: sample.subject, exact: true }).scrollIntoViewIfNeeded();
await page.screenshot({ path: testInfo.outputPath("memory-readable.png"), animations: "disabled" }); await page.screenshot({ path: testInfo.outputPath("memory-readable.png"), animations: "disabled" });
@@ -150,6 +180,14 @@ for (const width of [390, 1280, 2400]) {
await expect(page.locator(".thot-host")).toHaveAttribute("data-theme", "dark"); await expect(page.locator(".thot-host")).toHaveAttribute("data-theme", "dark");
await memoryDetail.getByRole("heading", { name: sample.subject, exact: true }).scrollIntoViewIfNeeded(); await memoryDetail.getByRole("heading", { name: sample.subject, exact: true }).scrollIntoViewIfNeeded();
await page.screenshot({ path: testInfo.outputPath("memory-readable-dark.png"), animations: "disabled" }); await page.screenshot({ path: testInfo.outputPath("memory-readable-dark.png"), animations: "disabled" });
await expect(content.locator("p").first()).toHaveCSS("line-height", "26.4px");
await page.getByRole("button", { name: memoryFormattingExamples[1].subject, exact: true }).click();
await expect(memoryDetail.getByRole("heading", { name: "Prima del conteggio" })).toHaveCSS("font-size", "16px");
await expect(memoryDetail.getByRole("heading", { name: "Prima del conteggio" })).toHaveCSS("font-family", bodyFamily);
await memoryDetail.locator(".thot-knowledge-title").scrollIntoViewIfNeeded();
await page.screenshot({ path: testInfo.outputPath("memory-headings-dark.png"), animations: "disabled" });
await page.getByRole("button", { name: memoryFormattingExamples[2].subject, exact: true }).click();
await expect(memoryDetail.locator("pre")).toHaveCSS("font-size", "14px");
expect((await inspect(page)).overflow).toBe(0); expect((await inspect(page)).overflow).toBe(0);
expect(writes).toEqual([]); expect(writes).toEqual([]);
}); });
+43 -1
View File
@@ -349,7 +349,49 @@ body.thot-full-document { margin: 0; }
.thot-prose h2 { font-size: var(--text-section); } .thot-prose h2 { font-size: var(--text-section); }
.thot-prose h3 { font-size: var(--text-body); } .thot-prose h3 { font-size: var(--text-body); }
.thot-prose :where(p, ul, ol, blockquote) { max-width: 75ch; } .thot-prose :where(p, ul, ol, blockquote) { max-width: 75ch; }
.thot-knowledge-prose { width: 100%; min-width: 0; max-width: none; } /* Shared reading contract for Memory and Evidence, independent of UI controls. */
.thot-knowledge-reader,
.thot-knowledge-prose {
font-family: var(--font-sans);
font-size: var(--text-body);
font-weight: 400;
line-height: 1.65;
letter-spacing: normal;
color: oklch(var(--foreground));
width: 100%;
min-width: 0;
max-width: none;
overflow-wrap: anywhere;
}
.thot-knowledge-title {
font: 600 var(--text-page)/1.3 var(--font-sans);
letter-spacing: -0.015em;
margin-top: 0.25rem;
}
.thot-knowledge-heading {
font: 600 var(--text-section)/1.4 var(--font-sans);
letter-spacing: normal;
margin: 0 0 0.5rem;
}
.thot-knowledge-meta {
font: 400 var(--text-control)/1.5 var(--font-sans);
letter-spacing: normal;
}
.thot-knowledge-meta dt { font-weight: 600; margin-bottom: 0.25rem; }
.thot-knowledge-reader code,
.thot-knowledge-reader pre,
.thot-prose.thot-knowledge-prose code {
font-family: var(--font-mono);
font-size: var(--text-control);
line-height: 1.65;
}
.thot-prose.thot-knowledge-prose :where(h1, h2, h3, h4, h5, h6) {
font: 600 var(--text-body)/1.5 var(--font-sans);
letter-spacing: normal;
margin: 1.5rem 0 0.5rem;
}
.thot-prose.thot-knowledge-prose > :first-child { margin-top: 0; }
.thot-knowledge-prose strong { font-weight: 600; }
.thot-knowledge-prose :where(p, ul, ol, blockquote) { max-width: none; } .thot-knowledge-prose :where(p, ul, ol, blockquote) { max-width: none; }
.thot-prose.thot-knowledge-prose p + p { margin-top: 1.25em; } .thot-prose.thot-knowledge-prose p + p { margin-top: 1.25em; }
.thot-knowledge-prose pre { white-space: pre-wrap; overflow-wrap: anywhere; } .thot-knowledge-prose pre { white-space: pre-wrap; overflow-wrap: anywhere; }
@@ -29,7 +29,7 @@ function CopyText({ text, label, showText = true }: { text: string; label: strin
const { t } = useI18n(); const { t } = useI18n();
const [notice, setNotice] = useState(""); const [notice, setNotice] = useState("");
return <div className="flex min-w-0 flex-wrap items-start gap-2"> return <div className="flex min-w-0 flex-wrap items-start gap-2">
{showText && <code className="min-w-0 flex-1 [overflow-wrap:anywhere] text-xs select-text">{text}</code>} {showText && <code className="min-w-0 flex-1 [overflow-wrap:anywhere] text-sm select-text">{text}</code>}
<Button size="icon" variant="ghost" title={t("Copy {label}", { label })} aria-label={t("Copy {label}", { label })} onClick={async () => { <Button size="icon" variant="ghost" title={t("Copy {label}", { label })} aria-label={t("Copy {label}", { label })} onClick={async () => {
try { await navigator.clipboard.writeText(text); setNotice("Copied"); } try { await navigator.clipboard.writeText(text); setNotice("Copied"); }
catch { setNotice("Select the text and copy it manually."); } catch { setNotice("Select the text and copy it manually."); }
@@ -131,15 +131,15 @@ function UnitDetail({ item, location }: { item: EvidenceUnit; location: Evidence
const { t } = useI18n(); const { t } = useI18n();
const source = item.provenance.kind === "manual" ? item.provenance.original : item.provenance; const source = item.provenance.kind === "manual" ? item.provenance.original : item.provenance;
const file = location.workspace ? `${location.workspace.replace(/\\/g, "/")}/evidence/${item.file}` : null; const file = location.workspace ? `${location.workspace.replace(/\\/g, "/")}/evidence/${item.file}` : null;
return <article className="min-w-0 w-full space-y-5 pb-8 [overflow-wrap:anywhere]"> return <article className="thot-knowledge-reader space-y-6 pb-8">
<header><p className="text-xs text-muted-foreground">{t(states[item.status])} · {item.purposes.map(purpose => t(purpose.replaceAll("_", " "))).join(", ")}</p><h2 className="mt-1 font-sans text-xl font-semibold">{item.title}</h2></header> <header><p className="thot-knowledge-meta text-muted-foreground">{t(states[item.status])} · {item.purposes.map(purpose => t(purpose.replaceAll("_", " "))).join(", ")}</p><h2 className="thot-knowledge-title">{item.title}</h2></header>
{file && <CopyText text={file} label={t("Evidence file path")} />} {file && <CopyText text={file} label={t("Evidence file path")} />}
{item.status !== "active" && <p className="text-sm text-muted-foreground">{["invalid", "removed"].includes(item.status) ? t("The file is invalid or removed. Its last active content is shown here.") : t("This is the file version awaiting consolidation.")} {t("The core continues to use its last active version.")}</p>} {item.status !== "active" && <p className="text-sm text-muted-foreground">{["invalid", "removed"].includes(item.status) ? t("The file is invalid or removed. Its last active content is shown here.") : t("This is the file version awaiting consolidation.")} {t("The core continues to use its last active version.")}</p>}
{Object.entries(item.applies_to).some(([, values]) => values.length > 0) && <section><h3 className="font-medium">{t("Scope")}</h3>{Object.entries(item.applies_to).map(([name, values]) => values.length > 0 && <p className="mt-2 break-words text-sm" key={name}><span className="capitalize">{t(name)}</span>: {values.join(", ")}</p>)}</section>} {Object.entries(item.applies_to).some(([, values]) => values.length > 0) && <section><h3 className="thot-knowledge-heading">{t("Scope")}</h3>{Object.entries(item.applies_to).map(([name, values]) => values.length > 0 && <p className="mt-3" key={name}><span className="capitalize">{t(name)}</span>: {values.join(", ")}</p>)}</section>}
{Object.entries(item.payload).map(([field, value]) => <section key={field}><h3 className="mb-2 text-sm font-semibold capitalize">{t(field.replaceAll("_", " "))}</h3>{typeof value === "string" ? field === "sql" ? <pre className="overflow-auto whitespace-pre-wrap [overflow-wrap:anywhere] rounded-md bg-muted p-3 text-sm">{value}</pre> : <MarkdownView source={value} readable /> : Array.isArray(value) ? <ul className="list-inside list-disc space-y-2 text-sm">{value.map(v => <li key={v}>{v}</li>)}</ul> : <dl className="space-y-2 text-sm">{Object.entries(value).map(([key, text]) => <div key={key}><dt className="font-mono font-medium">{key || t("(empty value)")}</dt><dd><MarkdownView source={text} readable /></dd></div>)}</dl>}</section>)} {Object.entries(item.payload).map(([field, value]) => <section key={field}><h3 className="thot-knowledge-heading capitalize">{t(field.replaceAll("_", " "))}</h3>{typeof value === "string" ? field === "sql" ? <pre className="overflow-auto whitespace-pre-wrap [overflow-wrap:anywhere] rounded-md bg-muted p-3">{value}</pre> : <MarkdownView source={value} readable /> : Array.isArray(value) ? <ul className="list-outside list-disc space-y-2 pl-5">{value.map(v => <li key={v}>{v}</li>)}</ul> : <dl className="space-y-4">{Object.entries(value).map(([key, text]) => <div key={key}><dt className="mb-2 font-semibold">{key ? <code>{key}</code> : t("(empty value)")}</dt><dd><MarkdownView source={text} readable /></dd></div>)}</dl>}</section>)}
<section className="border-t pt-4"><h3 className="font-medium">{t("Provenance")}</h3><p className="mt-2 text-sm">{item.provenance.kind === "manual" ? t("Manual declaration by {author}.", { author: item.provenance.declared_by ?? "" }) : t("Derived from a source document.")}</p> <section className="border-t pt-6"><h3 className="thot-knowledge-heading">{t("Provenance")}</h3><p>{item.provenance.kind === "manual" ? t("Manual declaration by {author}.", { author: item.provenance.declared_by ?? "" }) : t("Derived from a source document.")}</p>
{source?.source_file && <><p className="mt-2 [overflow-wrap:anywhere] text-xs">{item.provenance.kind === "manual" ? t("Original document (lineage): ") : t("Source: ")}{source.source_file}</p>{source.supporting_excerpts?.map((text, i) => <div key={i} className="mt-4"><MarkdownView source={text} readable /></div>)}</>} {source?.source_file && <><p className="thot-knowledge-meta mt-3">{item.provenance.kind === "manual" ? t("Original document (lineage): ") : t("Source: ")}<code>{source.source_file}</code></p>{source.supporting_excerpts?.map((text, i) => <div key={i} className="mt-5"><MarkdownView source={text} readable /></div>)}</>}
</section> </section>
{item.review_items.length > 0 && <section><h3 className="font-medium">{t("Review required")}</h3>{item.review_items.map((review, i) => <p key={i} className="mt-2 text-sm">{t(review.message)}</p>)}</section>} {item.review_items.length > 0 && <section><h3 className="thot-knowledge-heading">{t("Review required")}</h3>{item.review_items.map((review, i) => <p key={i} className="mt-3">{t(review.message)}</p>)}</section>}
</article>; </article>;
} }
+13 -13
View File
@@ -180,21 +180,21 @@ function MemoryWorkspace({ workspace, onDirtyChange, onBusyChange }: {
</section> </section>
<section aria-label={t("Memory detail")} className="thot-administration-detail"> <section aria-label={t("Memory detail")} className="thot-administration-detail">
{!draft && !selected && <p className="py-8 text-sm text-muted-foreground">{t("Select a card to inspect its content, scope and provenance.")}</p>} {!draft && !selected && <p className="py-8 text-sm text-muted-foreground">{t("Select a card to inspect its content, scope and provenance.")}</p>}
{selected && !draft && <div className="min-w-0 w-full space-y-5 [overflow-wrap:anywhere]"> {selected && !draft && <div className="thot-knowledge-reader space-y-6">
{formattingExample && <p role="note" className="rounded-md border bg-muted p-3 text-sm">{t("Simulated cards for visual review only. Not saved, indexed or sent to the model.")}</p>} {formattingExample && <p role="note" className="rounded-md border bg-muted p-3 text-sm">{t("Simulated cards for visual review only. Not saved, indexed or sent to the model.")}</p>}
<div><p className="text-xs text-muted-foreground">{t(families[selected.family])}</p><h2 className="mt-1 break-words font-sans text-xl font-semibold">{selected.subject}</h2></div> <div><p className="thot-knowledge-meta text-muted-foreground">{t(families[selected.family])}</p><h2 className="thot-knowledge-title">{selected.subject}</h2></div>
<dl className="space-y-4 text-sm"> <dl className="space-y-6">
{(["detail", "scope", "rationale", "question", "sql"] as const).filter(k => selected[k]).map(key => <div key={key}> {(["detail", "scope", "rationale", "question", "sql"] as const).filter(k => selected[k]).map(key => <div key={key}>
<dt className="mb-1 font-semibold capitalize">{key === "detail" ? t("Content") : key === "sql" ? t("Approved SQL") : t(key)}</dt> <dt className="thot-knowledge-heading capitalize">{key === "detail" ? t("Content") : key === "sql" ? t("Approved SQL") : t(key)}</dt>
<dd className="min-w-0 w-full">{key === "sql" ? <pre className="whitespace-pre-wrap [overflow-wrap:anywhere] rounded-md bg-muted p-3 font-mono text-sm">{selected[key]}</pre> : <MarkdownView source={selected[key]} readable />}</dd></div>)} <dd className="min-w-0 w-full">{key === "sql" ? <pre className="whitespace-pre-wrap [overflow-wrap:anywhere] rounded-md bg-muted p-3">{selected[key]}</pre> : <MarkdownView source={selected[key]} readable />}</dd></div>)}
<div><dt className="font-semibold">{t("Provenance")}</dt><dd>{formattingExample ? t("Formatting example, not workspace knowledge.") : selected.origin === "manual" ? t("Created manually") : t("Workflow session {session}", { session: selected.session_id ?? "" })}</dd></div> <div className="border-t pt-6"><dt className="thot-knowledge-heading">{t("Provenance")}</dt><dd>{formattingExample ? t("Formatting example, not workspace knowledge.") : selected.origin === "manual" ? t("Created manually") : t("Workflow session {session}", { session: selected.session_id ?? "" })}</dd></div>
{selected.decision_seq !== null && <div><dt className="font-semibold">{t("Source decision")}</dt><dd>{selected.decision_seq}</dd></div>} {selected.decision_seq !== null && <div className="thot-knowledge-meta"><dt>{t("Source decision")}</dt><dd>{selected.decision_seq}</dd></div>}
<div><dt className="font-semibold">{t("Card ID")}</dt><dd className="break-all font-mono text-xs">{selected.id}</dd></div> <div className="thot-knowledge-meta"><dt>{t("Card ID")}</dt><dd><code>{selected.id}</code></dd></div>
<div><dt className="font-semibold">{t("Updated")}</dt><dd>{new Date(selected.updated_at).toLocaleString(getLocale())}</dd></div> <div className="thot-knowledge-meta"><dt>{t("Updated")}</dt><dd>{new Date(selected.updated_at).toLocaleString(getLocale())}</dd></div>
<div><dt className="font-semibold">{t("Created")}</dt><dd>{new Date(selected.created_at).toLocaleString(getLocale())}</dd></div> <div className="thot-knowledge-meta"><dt>{t("Created")}</dt><dd>{new Date(selected.created_at).toLocaleString(getLocale())}</dd></div>
{selected.concepts.length > 0 && <div><dt className="font-semibold">{t("Concepts")}</dt><dd>{selected.concepts.join(", ")}</dd></div>} {selected.concepts.length > 0 && <div><dt className="thot-knowledge-heading">{t("Concepts")}</dt><dd>{selected.concepts.join(", ")}</dd></div>}
{selected.dependencies.length > 0 && <div><dt className="font-semibold">{t("Schema dependencies")}</dt><dd><ul>{selected.dependencies.map((d, i) => <li key={i}>{[d.database, d.schema_name, d.table, d.column].filter(Boolean).join(" / ")}</li>)}</ul></dd></div>} {selected.dependencies.length > 0 && <div><dt className="thot-knowledge-heading">{t("Schema dependencies")}</dt><dd><ul className="list-outside list-disc space-y-2 pl-5">{selected.dependencies.map((d, i) => <li key={i}><code>{[d.database, d.schema_name, d.table, d.column].filter(Boolean).join(" / ")}</code></li>)}</ul></dd></div>}
{selected.links.length > 0 && <div><dt className="font-semibold">{t("Linked cards")}</dt><dd><ul>{selected.links.map(link => <li key={link.target_id} className="my-2"><button className="text-left text-primary underline" onClick={() => void open(link.target_id)}>{link.meaning}</button></li>)}</ul></dd></div>} {selected.links.length > 0 && <div><dt className="thot-knowledge-heading">{t("Linked cards")}</dt><dd><ul>{selected.links.map(link => <li key={link.target_id} className="my-2"><button className="text-left text-primary underline" onClick={() => void open(link.target_id)}>{link.meaning}</button></li>)}</ul></dd></div>}
</dl> </dl>
{!formattingExample && <div className="flex gap-2"><Button disabled={busy} onClick={() => setDraft(memoryInput(selected))}>{t("Edit card")}</Button> {!formattingExample && <div className="flex gap-2"><Button disabled={busy} onClick={() => setDraft(memoryInput(selected))}>{t("Edit card")}</Button>
<Button variant="outline" disabled={busy} onClick={() => setDeleteConfirm(true)}>{t("Delete card")}</Button></div>} <Button variant="outline" disabled={busy} onClick={() => setDeleteConfirm(true)}>{t("Delete card")}</Button></div>}