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
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
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
+13
View File
@@ -48,6 +48,19 @@ PSD Gitea push and production deployment are still pending.
## 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
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 sample = memoryFormattingExamples[0];
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.goto("/");
await admin(page, "Evidence");
@@ -120,6 +120,26 @@ for (const width of [390, 1280, 2400]) {
const rule = article.locator(".thot-knowledge-prose").first();
await expect(rule.locator(":scope > p")).not.toHaveCount(1);
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 => {
const parent = el.parentElement!;
const css = getComputedStyle(parent);
@@ -129,7 +149,7 @@ for (const width of [390, 1280, 2400]) {
await expect(copy).toHaveText("");
await expect(copy.locator("svg")).toHaveCount(1);
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" });
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("p").first()).toHaveCSS("max-width", "none");
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);
await memoryDetail.getByRole("heading", { name: sample.subject, exact: true }).scrollIntoViewIfNeeded();
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 memoryDetail.getByRole("heading", { name: sample.subject, exact: true }).scrollIntoViewIfNeeded();
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(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 h3 { font-size: var(--text-body); }
.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-prose.thot-knowledge-prose p + p { margin-top: 1.25em; }
.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 [notice, setNotice] = useState("");
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 () => {
try { await navigator.clipboard.writeText(text); setNotice("Copied"); }
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 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="min-w-0 w-full space-y-5 pb-8 [overflow-wrap:anywhere]">
<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>
return <article className="thot-knowledge-reader space-y-6 pb-8">
<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")} />}
{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.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>)}
<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>
{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>)}</>}
{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="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-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="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>
{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>;
}
+13 -13
View File
@@ -180,21 +180,21 @@ function MemoryWorkspace({ workspace, onDirtyChange, onBusyChange }: {
</section>
<section aria-label={t("Memory detail")} className="thot-administration-detail">
{!draft && !selected && <p className="py-8 text-sm text-muted-foreground">{t("Select a card to inspect its content, scope and provenance.")}</p>}
{selected && !draft && <div className="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>}
<div><p className="text-xs text-muted-foreground">{t(families[selected.family])}</p><h2 className="mt-1 break-words font-sans text-xl font-semibold">{selected.subject}</h2></div>
<dl className="space-y-4 text-sm">
<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-6">
{(["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>
<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>)}
<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>
{selected.decision_seq !== null && <div><dt className="font-semibold">{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><dt className="font-semibold">{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>
{selected.concepts.length > 0 && <div><dt className="font-semibold">{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.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>}
<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">{selected[key]}</pre> : <MarkdownView source={selected[key]} readable />}</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 className="thot-knowledge-meta"><dt>{t("Source decision")}</dt><dd>{selected.decision_seq}</dd></div>}
<div className="thot-knowledge-meta"><dt>{t("Card ID")}</dt><dd><code>{selected.id}</code></dd></div>
<div className="thot-knowledge-meta"><dt>{t("Updated")}</dt><dd>{new Date(selected.updated_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="thot-knowledge-heading">{t("Concepts")}</dt><dd>{selected.concepts.join(", ")}</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="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>
{!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>}