From 3535fda958e162a59283b694bc15c4b3f9f57e46 Mon Sep 17 00:00:00 2001 From: Codex Date: Sun, 13 Sep 2026 16:52:53 +0200 Subject: [PATCH] fix(ui): improve knowledge reading and add isolated formatting examples --- DESIGN.md | 13 ++++ PROJECT_STATE.md | 14 ++++ docs/reports/2026-09-13-knowledge-reading.md | 77 +++++++++++++++++++ frontend/e2e/ui-visual-review.spec.ts | 53 +++++++++++++ frontend/src/i18n/locales/it-admin.ts | 3 + frontend/src/index.css | 4 + .../src/shell/EvidenceManagementPage.test.tsx | 18 +++++ frontend/src/shell/EvidenceManagementPage.tsx | 15 ++-- .../src/shell/MemoryManagementPage.test.tsx | 28 +++++++ frontend/src/shell/MemoryManagementPage.tsx | 33 +++++--- .../src/shell/memoryFormattingExamples.ts | 43 +++++++++++ .../viewers/MarkdownView.readable.test.tsx | 32 ++++++++ frontend/src/viewers/MarkdownView.tsx | 7 +- frontend/src/viewers/readableParagraphs.ts | 41 ++++++++++ 14 files changed, 362 insertions(+), 19 deletions(-) create mode 100644 docs/reports/2026-09-13-knowledge-reading.md create mode 100644 frontend/src/shell/memoryFormattingExamples.ts create mode 100644 frontend/src/viewers/MarkdownView.readable.test.tsx create mode 100644 frontend/src/viewers/readableParagraphs.ts diff --git a/DESIGN.md b/DESIGN.md index 58095da7..0e83b63e 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -351,6 +351,19 @@ default, hover, focus, active, disabled, loading, and error behavior where those ### Curated Evidence Documents +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 +boundaries outside inline code and links; authored Markdown structure and stored +content are unchanged. Paragraph spacing is 1.25em. Scope and provenance share the +available width; provenance excerpts render Markdown rather than literal markers. +Copy actions use the two-overlapping-sheets icon, an accessible name/tooltip and +live success/failure feedback instead of a visible Copy label. + +Memory has four explicitly FAKE formatting examples, one per family, in a separate +expandable section. They reuse the real detail reader but never enter persistence, +indexing, link search or model recall, and expose no edit/delete/save actions. + Curated evidence follows a fixed reading order: title, compact type and purpose summary, scope, typed content, supporting excerpts, review items, then technical provenance. Curated v4 files use short, visible YAML frontmatter for identity and classification. The Markdown title and diff --git a/PROJECT_STATE.md b/PROJECT_STATE.md index 7d80ad19..6d7acfc3 100644 --- a/PROJECT_STATE.md +++ b/PROJECT_STATE.md @@ -48,6 +48,20 @@ PSD Gitea push and production deployment are still pending. ## Current product shape +### Knowledge reading and isolated fake Memory examples — 2026-09-13 + +Memory/Evidence details now use all available width, with display-only paragraph +splitting for long plain prose and preserved code/Markdown/source data. Evidence +provenance renders Markdown; copy controls are accessible two-sheet icons. +Memory's separate Formatting examples section contains four FAKE cards (one per +family), automatically expanded for an empty archive. These client-side examples +cannot be edited/saved/indexed and are never submitted to the model or Memory API. +No real archive content was changed. 762 tests, 18 browser scenarios, typecheck, +i18n and Docker build pass. Only the Mac frontend was recreated: `fc4286b5406d`, +image `cbe0fe0dce55`, healthy; other services unchanged. Rollback image: +`thothii-frontend:before-knowledge-reading-20260913`. Details in +`docs/reports/2026-09-13-knowledge-reading.md`; no PSD/Omics deployment. + ### Unified Session entry and complete tab borders — 2026-09-13 The sidebar has one Session/Sessione button: return to the current unfinished diff --git a/docs/reports/2026-09-13-knowledge-reading.md b/docs/reports/2026-09-13-knowledge-reading.md new file mode 100644 index 00000000..52397d21 --- /dev/null +++ b/docs/reports/2026-09-13-knowledge-reading.md @@ -0,0 +1,77 @@ +# Lettura di Evidence e Memory + +Data: 13 settembre 2026. Richiesta: eliminare il muro di testo nelle regole, +usare la larghezza disponibile, fornire Memory finte per la verifica grafica e +sostituire i pulsanti Copia con l'icona a due fogli. + +## Intervento + +Rimossi il limite di 75ch dal dettaglio Evidence e quello di 72ch dai campi Memory. +La variante di lettura Knowledge elimina anche il limite interno dei paragrafi +Markdown, mantenendo il comportamento responsive del contenitore. Gli altri +visualizzatori non cambiano impaginazione. Codice e identificatori lunghi possono +andare a capo quando necessario, senza allargare la pagina. + +Per paragrafi di almeno 360 caratteri, la variante introduce separazioni visive +dopo almeno 180 caratteri, cercando punti, punti e virgola, punti interrogativi +o esclamativi nel testo ordinario. Non divide dentro codice inline, enfasi o link; +elenchi, titoli, paragrafi brevi e blocchi SQL già strutturati rimangono intatti. +La spaziatura tra paragrafi è 1,25em. Si tratta di un intervento sul rendering +Markdown, non di una riscrittura o un salvataggio dei documenti. Nessuna conoscenza +di dominio viene generata dal visualizzatore. + +Ambito, Provenienza e Regola occupano l'intera larghezza del dettaglio. Gli estratti +di provenienza interpretano Markdown, senza mostrare letteralmente asterischi e +backtick. Copia usa l'icona Lucide Copy con nome accessibile e tooltip; il percorso +completo viene copiato senza trasformazioni e gli errori restano annunciati. + +Impeccable ha guidato la leggibilità, la separazione dei paragrafi e l'accessibilità; +la larghezza piena segue la richiesta esplicita dell'utente anche oltre la misura +di lettura ordinaria del design system. + +## Memory simulate + +In **Administration → Memory → Formatting examples**, oppure **Amministrazione → +Memory → Esempi di formattazione**, sono disponibili quattro schede FAKE: + +- chiarimento di dominio con una regola lunga; +- regola SQL con titoli, elenchi e identificatori; +- domanda risolta con SQL dimostrativo; +- errore spiegato con paragrafi distinti. + +La sezione si apre automaticamente se l'archivio reale è vuoto. Gli esempi sono +fixture frontend in `frontend/src/shell/memoryFormattingExamples.ts`, separate +dai risultati dell'archivio e dai suoi conteggi. Riutilizzano il lettore reale, +ma non offrono Edit/Delete/Save, non hanno dipendenze o link a schede reali e non +vengono inviati ad alcuna API Memory. Nessun inserimento PostgreSQL, indicizzazione +Qdrant, consolidamento Evidence o invio al modello è avvenuto. I nomi demo e gli +identificatori non sono riferimenti al DWH operativo. + +Si possono richiudere senza cancellazioni. Una futura rimozione degli esempi è +una modifica del frontend, non una pulizia dei dati. New card dopo un esempio +apre una scheda vuota, senza copiarne il contenuto simulato. + +## Verifiche e consegna locale + +- 762 test frontend in 91 file, tutti superati. +- 18 scenari Playwright, inclusi reader a 390, 1280 e 2400 px, tema scuro, + assenza di overflow, larghezza dei paragrafi e separazione effettiva. +- Test di conservazione di parole, codice e destinazioni dei link, struttura + Markdown e comportamento invariato dei visualizzatori ordinari. +- Clipboard: contenuto copiato esatto, nome accessibile e fallimento gestito. +- Fixture Memory: nessuna richiesta di scrittura o caricamento di una falsa + scheda dal server; nessuna azione di modifica disponibile. +- Typecheck, controllo di 1686 traduzioni italiane/1707 riferimenti statici e build + Docker superati. Screenshot desktop, desktop largo e mobile scuro controllati. + +Il Mac serve la nuova variante CSS. Solo frontend è stato ricreato, container +`fc4286b5406d`, immagine `cbe0fe0dce55`, healthy. Core `3c3c0739b9af`, catalogo +`89755e439dc7`, Qdrant `52ebd5c47955` ed embedding `7f690e534eda` restano invariati +e healthy. Non è stato eseguito un deploy Omics o PSD. + +Rollback locale, senza toccare altri servizi o volumi: + +```bash +docker image tag thothii-frontend:before-knowledge-reading-20260913 thothii-frontend:local +bash /private/tmp/thothii-memory-preview.sh up -d --no-deps --no-build --wait frontend +``` diff --git a/frontend/e2e/ui-visual-review.spec.ts b/frontend/e2e/ui-visual-review.spec.ts index 4c74819d..9c4f64c0 100644 --- a/frontend/e2e/ui-visual-review.spec.ts +++ b/frontend/e2e/ui-visual-review.spec.ts @@ -1,5 +1,6 @@ import { expect, test, type Page } from "@playwright/test"; import { canonicalWorkspaceFixture, workspaceRevisionFixture, workspaceSummaryFixture } from "../src/test/workspace-fixtures"; +import { memoryFormattingExamples } from "../src/shell/memoryFormattingExamples"; // These tests never forward API requests to an installation or invoke a real model. const workspace = "visual-fixture"; @@ -102,6 +103,58 @@ async function inspect(page: Page) { }); } +for (const width of [390, 1280, 2400]) { + test(`knowledge readers use available width and readable paragraphs at ${width}px`, async ({ page }, testInfo) => { + await page.setViewportSize({ width, height: 1100 }); + 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."] } }; + await page.route(`**/api/workspaces/${workspace}/evidence/*`, route => route.fulfill({ json: { ...evidencePage, item: longEvidence } })); + await page.goto("/"); + await admin(page, "Evidence"); + await page.getByRole("button", { name: evidence.title, exact: true }).click(); + const evidenceDetail = page.getByRole("region", { name: "Evidence detail" }); + const article = evidenceDetail.locator("article"); + await expect(article).toHaveCSS("max-width", "none"); + 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"); + expect(await article.evaluate(el => { + const parent = el.parentElement!; + const css = getComputedStyle(parent); + return Math.abs(el.getBoundingClientRect().width - (parent.clientWidth - parseFloat(css.paddingLeft) - parseFloat(css.paddingRight))); + })).toBeLessThan(2); + const copy = article.getByRole("button", { name: "Copy Evidence file path", exact: true }); + 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 page.screenshot({ path: testInfo.outputPath("evidence-readable.png"), animations: "disabled" }); + expect((await inspect(page)).overflow).toBe(0); + + await admin(page, "Memory"); + await page.getByRole("button", { name: sample.subject, exact: true }).click(); + const memoryDetail = page.getByRole("region", { name: "Memory detail" }); + await expect(memoryDetail.getByRole("note")).toContainText("Not saved, indexed or sent to the model."); + const content = memoryDetail.locator(".thot-knowledge-prose").first(); + 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"); + 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" }); + await expect(memoryDetail.getByRole("button", { name: "Edit card", exact: true })).toHaveCount(0); + expect((await inspect(page)).overflow).toBe(0); + await page.getByRole("button", { name: "Use dark theme", exact: true }).click(); + 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" }); + expect((await inspect(page)).overflow).toBe(0); + expect(writes).toEqual([]); + }); +} + for (const width of [390, 1280]) { test(`full shell keeps the visual revision across language and theme changes at ${width}px`, async ({ page }, testInfo) => { await page.setViewportSize({ width, height: 1000 }); diff --git a/frontend/src/i18n/locales/it-admin.ts b/frontend/src/i18n/locales/it-admin.ts index 607cb022..5a7a400f 100644 --- a/frontend/src/i18n/locales/it-admin.ts +++ b/frontend/src/i18n/locales/it-admin.ts @@ -1,5 +1,8 @@ // English gettext message IDs. Workspace content, SQL and identifiers are never translated. export const itAdmin: Record = { + "Formatting examples": "Esempi di formattazione", + "Simulated cards for visual review only. Not saved, indexed or sent to the model.": "Schede simulate solo per la verifica grafica. Non vengono salvate, indicizzate o inviate al modello.", + "Formatting example, not workspace knowledge.": "Esempio di formattazione, non conoscenza del workspace.", "Import sources, then review proposed changes before activation.": "Importa le fonti, poi rivedi le modifiche proposte prima dell'attivazione.", "Which sources are imported?": "Quali fonti vengono importate?", "Place new Markdown drafts in": "Inserisci le nuove bozze Markdown in", diff --git a/frontend/src/index.css b/frontend/src/index.css index 0f1f8e26..0ae36793 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -349,6 +349,10 @@ 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; } + .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; } .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; } diff --git a/frontend/src/shell/EvidenceManagementPage.test.tsx b/frontend/src/shell/EvidenceManagementPage.test.tsx index 41800654..b7d689f6 100644 --- a/frontend/src/shell/EvidenceManagementPage.test.tsx +++ b/frontend/src/shell/EvidenceManagementPage.test.tsx @@ -22,6 +22,24 @@ beforeEach(() => server.use( http.get("/api/workspaces/sales/evidence", () => HttpResponse.json(page)), http.get("/api/workspaces/sales/evidence/evidence:order-key", () => HttpResponse.json(page)), )); + +test("copy uses an accessible icon and copies the unmodified full path", async () => { + const user = userEvent.setup(); + const copy = vi.spyOn(navigator.clipboard, "writeText").mockResolvedValue(); + renderPage(); await choose(); + await user.click(await screen.findByRole("button", { name: "Order key" })); + const detail = screen.getByRole("region", { name: "Evidence detail" }); + const button = within(detail).getByRole("button", { name: "Copy Evidence file path" }); + expect(button).toHaveTextContent(""); + expect(button.querySelector("svg")).not.toBeNull(); + await user.click(button); + expect(copy).toHaveBeenCalledWith("/srv/workspaces/repo/sales/evidence/curated/domain/order-key.md"); + expect(within(detail).getByRole("status")).toHaveTextContent("Copied"); + copy.mockRejectedValueOnce(new Error("Clipboard unavailable")); + await user.click(button); + expect(within(detail).getByRole("status")).toHaveTextContent("Select the text and copy it manually."); + copy.mockRestore(); +}); async function choose() { await screen.findByRole("option", { name: "Sales" }); await userEvent.selectOptions(screen.getByLabelText("Workspace"), "sales"); } test("switches Evidence controls and detail labels without translating archive content", async () => { renderPage(); await choose(); diff --git a/frontend/src/shell/EvidenceManagementPage.tsx b/frontend/src/shell/EvidenceManagementPage.tsx index 4943056e..b7d64588 100644 --- a/frontend/src/shell/EvidenceManagementPage.tsx +++ b/frontend/src/shell/EvidenceManagementPage.tsx @@ -1,5 +1,6 @@ import { useI18n } from "../i18n"; import { useState } from "react"; +import { Copy } from "lucide-react"; import { useQuery } from "@tanstack/react-query"; import { apiFetch, apiErrorMessage } from "../api/client"; import { listEvidence, getEvidence, type EvidencePage, type EvidenceUnit } from "../api/evidence"; @@ -28,11 +29,11 @@ function CopyText({ text, label, showText = true }: { text: string; label: strin const { t } = useI18n(); const [notice, setNotice] = useState(""); return
- {showText && {text}} - {t(notice)} + }}>
; } @@ -113,7 +114,7 @@ function Instructions({ data }: { data: EvidencePage }) {

{data.pending_revision ? t("Consolidation is incomplete. The previous active content remains available.") : data.active_revision ? t("The core uses the last successfully consolidated version.") : t("Run the first consolidation to convert and activate the local archive.")}

{t("Edit files, consolidate and commit")} {data.location.workspace ?

{t("Workspace on the installation host")}

:

{t("A host file path is unavailable for this installation. Configure a persistent workspace-registry bind mount before editing files from the host.")}

} -
    +
    1. {t("Edit the files under")} evidence/curated/<kind>/ {t("with your preferred editor. Keep existing IDs. Remove a unit's file to delete it; keep the curated directory.")}
    2. {t("Check Git status and the diff. Saving a file does not update recall.")}{data.location.git_commands.slice(0, 2).map(command =>
      )}
    3. {t("From the installation directory, run this command. It validates and activates Evidence without a full preprocessing run. If needed, add")} --installation /absolute/path/thothii-installation.yaml {t("immediately after")} tht. @@ -130,14 +131,14 @@ 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
      + return

      {t(states[item.status])} · {item.purposes.map(purpose => t(purpose.replaceAll("_", " "))).join(", ")}

      {item.title}

      {file && } {item.status !== "active" &&

      {["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.")}

      } {Object.entries(item.applies_to).some(([, values]) => values.length > 0) &&

      {t("Scope")}

      {Object.entries(item.applies_to).map(([name, values]) => values.length > 0 &&

      {t(name)}: {values.join(", ")}

      )}
      } - {Object.entries(item.payload).map(([field, value]) =>

      {t(field.replaceAll("_", " "))}

      {typeof value === "string" ? field === "sql" ?
      {value}
      : : Array.isArray(value) ?
        {value.map(v =>
      • {v}
      • )}
      :
      {Object.entries(value).map(([key, text]) =>
      {key || t("(empty value)")}
      {text}
      )}
      }
      )} + {Object.entries(item.payload).map(([field, value]) =>

      {t(field.replaceAll("_", " "))}

      {typeof value === "string" ? field === "sql" ?
      {value}
      : : Array.isArray(value) ?
        {value.map(v =>
      • {v}
      • )}
      :
      {Object.entries(value).map(([key, text]) =>
      {key || t("(empty value)")}
      )}
      }
      )}

      {t("Provenance")}

      {item.provenance.kind === "manual" ? t("Manual declaration by {author}.", { author: item.provenance.declared_by ?? "" }) : t("Derived from a source document.")}

      - {source?.source_file && <>

      {item.provenance.kind === "manual" ? t("Original document (lineage): ") : t("Source: ")}{source.source_file}

      {source.supporting_excerpts?.map((text, i) =>

      “{text}”

      )}} + {source?.source_file && <>

      {item.provenance.kind === "manual" ? t("Original document (lineage): ") : t("Source: ")}{source.source_file}

      {source.supporting_excerpts?.map((text, i) =>
      )}}
      {item.review_items.length > 0 &&

      {t("Review required")}

      {item.review_items.map((review, i) =>

      {t(review.message)}

      )}
      }
      ; diff --git a/frontend/src/shell/MemoryManagementPage.test.tsx b/frontend/src/shell/MemoryManagementPage.test.tsx index 8405657d..68336bef 100644 --- a/frontend/src/shell/MemoryManagementPage.test.tsx +++ b/frontend/src/shell/MemoryManagementPage.test.tsx @@ -6,6 +6,7 @@ import { http, HttpResponse } from "msw"; import { server } from "../test/msw"; import { MemoryManagementPage } from "./MemoryManagementPage"; import type { MemoryCard } from "../api/memory"; +import { memoryFormattingExamples } from "./memoryFormattingExamples"; const id = "mem-11111111-1111-4111-8111-111111111111"; const fixture: MemoryCard = { id, workspace_id: "sales", family: "domain_clarification", subject: "Order", @@ -31,6 +32,33 @@ async function chooseWorkspace() { await userEvent.selectOptions(screen.getByLabelText("Workspace"), "sales"); } +test("formatting examples use the real reader without persistence or editing actions", async () => { + const writes: string[] = []; + const cardReads: string[] = []; + server.use( + http.all("/api/workspaces/sales/memory/*", ({ request }) => { + if (request.method !== "GET") writes.push(request.method); + else cardReads.push(new URL(request.url).pathname); + return HttpResponse.json([]); + }), + ); + renderPage(); await chooseWorkspace(); + await userEvent.click(screen.getByText("Formatting examples", { selector: "summary" })); + const detail = screen.getByRole("region", { name: "Memory detail" }); + for (const card of memoryFormattingExamples) { + await userEvent.click(screen.getByRole("button", { name: card.subject })); + expect(within(detail).getByRole("heading", { name: card.subject })).toBeVisible(); + expect(within(detail).getByRole("note")).toHaveTextContent("Not saved, indexed or sent to the model."); + expect(within(detail).queryByRole("button", { name: "Edit card" })).not.toBeInTheDocument(); + expect(within(detail).queryByRole("button", { name: "Delete card" })).not.toBeInTheDocument(); + expect(within(detail).queryByRole("button", { name: "Save card" })).not.toBeInTheDocument(); + } + expect(writes).toEqual([]); + expect(cardReads.every(path => path.endsWith("/pending"))).toBe(true); + await userEvent.click(screen.getByRole("button", { name: "New card" })); + expect(screen.getByLabelText("Title")).toHaveValue(""); +}); + test("switches Memory editor controls in place and preserves the draft", async () => { renderPage(); await chooseWorkspace(); await userEvent.click(screen.getByRole("button", { name: "New card" })); diff --git a/frontend/src/shell/MemoryManagementPage.tsx b/frontend/src/shell/MemoryManagementPage.tsx index 7159981d..ac4af093 100644 --- a/frontend/src/shell/MemoryManagementPage.tsx +++ b/frontend/src/shell/MemoryManagementPage.tsx @@ -10,6 +10,8 @@ import { import { useAuthGeneration } from "../auth/authState"; import { AdministrationHeader } from "./administration/AdministrationPage"; import { useWorkingContextScope } from "../workspaces/WorkingContext"; +import { MarkdownView } from "../viewers/MarkdownView"; +import { memoryFormattingExamples } from "./memoryFormattingExamples"; const families: Record = { domain_clarification: "Domain clarification", sql_rule: "SQL rule", @@ -61,6 +63,7 @@ function MemoryWorkspace({ workspace, onDirtyChange, onBusyChange }: { const [filters, setFilters] = useState>({ q: "", family: "", sort: "updated_at", direction: "desc" }); const [page, setPage] = useState(1); const [selected, setSelected] = useState(null); + const [formattingExample, setFormattingExample] = useState(false); const [draft, setDraft] = useState(null); const [busy, setBusy] = useState(false); const [notice, setNotice] = useState(""); @@ -93,7 +96,7 @@ function MemoryWorkspace({ workspace, onDirtyChange, onBusyChange }: { async function open(id: string) { if (busy || !leaveDraft()) return; setBusy(true); setError(""); setDeleteConfirm(false); - try { const card = await getMemory(workspace, id); setSelected(card); setDraft(null); } + try { const card = await getMemory(workspace, id); setSelected(card); setFormattingExample(false); setDraft(null); } catch (e) { setError(apiErrorMessage(e)); } finally { setBusy(false); } } @@ -106,7 +109,7 @@ function MemoryWorkspace({ workspace, onDirtyChange, onBusyChange }: { : outcome.action === "delete" ? "Card deleted. Index cleanup is incomplete; retry below." : "Saved. Index update is incomplete; retry below. The previous content is excluded from recall."); if (outcome.action === "delete" && outcome.id === selected?.id) { setSelected(null); setDraft(null); } - else if (outcome.card) { setSelected(outcome.card); setDraft(null); } + else if (outcome.card) { setSelected(outcome.card); setFormattingExample(false); setDraft(null); } setDeleteConfirm(false); await refresh(); } catch (e) { setError(apiErrorMessage(e)); } finally { setBusy(false); } @@ -128,7 +131,7 @@ function MemoryWorkspace({ workspace, onDirtyChange, onBusyChange }: { - +
      {t("More filters")}
      @@ -163,16 +166,28 @@ function MemoryWorkspace({ workspace, onDirtyChange, onBusyChange }: { } } +
      + {t("Formatting examples")} +

      {t("Simulated cards for visual review only. Not saved, indexed or sent to the model.")}

      +
        {memoryFormattingExamples.map(card =>
      • + +
      • )}
      +
      {!draft && !selected &&

      {t("Select a card to inspect its content, scope and provenance.")}

      } - {selected && !draft &&
      + {selected && !draft &&
      + {formattingExample &&

      {t("Simulated cards for visual review only. Not saved, indexed or sent to the model.")}

      }

      {t(families[selected.family])}

      {selected.subject}

      {(["detail", "scope", "rationale", "question", "sql"] as const).filter(k => selected[k]).map(key =>
      {key === "detail" ? t("Content") : key === "sql" ? t("Approved SQL") : t(key)}
      -
      {selected[key]}
      )} -
      {t("Provenance")}
      {selected.origin === "manual" ? t("Created manually") : t("Workflow session {session}", { session: selected.session_id ?? "" })}
      +
      {key === "sql" ?
      {selected[key]}
      : }
      )} +
      {t("Provenance")}
      {formattingExample ? t("Formatting example, not workspace knowledge.") : selected.origin === "manual" ? t("Created manually") : t("Workflow session {session}", { session: selected.session_id ?? "" })}
      {selected.decision_seq !== null &&
      {t("Source decision")}
      {selected.decision_seq}
      }
      {t("Card ID")}
      {selected.id}
      {t("Updated")}
      {new Date(selected.updated_at).toLocaleString(getLocale())}
      @@ -181,9 +196,9 @@ function MemoryWorkspace({ workspace, onDirtyChange, onBusyChange }: { {selected.dependencies.length > 0 &&
      {t("Schema dependencies")}
        {selected.dependencies.map((d, i) =>
      • {[d.database, d.schema_name, d.table, d.column].filter(Boolean).join(" / ")}
      • )}
      } {selected.links.length > 0 &&
      {t("Linked cards")}
        {selected.links.map(link =>
      • )}
      } -
      -
      - {deleteConfirm &&

      {t("Delete “")}{selected.subject}{t("” and its links? Other cards will be kept.")}

      + {!formattingExample &&
      +
      } + {!formattingExample && deleteConfirm &&

      {t("Delete “")}{selected.subject}{t("” and its links? Other cards will be kept.")}

      }
      } diff --git a/frontend/src/shell/memoryFormattingExamples.ts b/frontend/src/shell/memoryFormattingExamples.ts new file mode 100644 index 00000000..ea13befa --- /dev/null +++ b/frontend/src/shell/memoryFormattingExamples.ts @@ -0,0 +1,43 @@ +import type { MemoryCard, MemoryInput } from "../api/memory"; + +// Presentation fixtures only. Never pass these cards to persistence or recall. +const samples: MemoryInput[] = [ + { + family: "domain_clarification", subject: "FAKE · Distinguere eventi e soggetti", + detail: "Questo è un esempio inventato per verificare la leggibilità di una regola lunga, non una definizione approvata del workspace. Nel catalogo dimostrativo un soggetto può comparire in più eventi e ogni evento può contenere diverse osservazioni; la chiave `demo_visuale.event_id` identifica un evento, mentre `demo_visuale.subject_id` serve esclusivamente a raggruppare le righe dello stesso soggetto fittizio. Per mostrare la separazione tra paragrafi, immaginiamo di dover descrivere il criterio di conteggio e di confrontare due risultati costruiti a partire dallo stesso insieme di righe. Il numero degli eventi e quello dei soggetti possono essere diversi anche se nessuna riga è duplicata; questa frase serve soltanto a verificare il respiro del testo dopo un punto e virgola. Nessuna delle tabelle o delle colonne citate esiste nel database operativo e questo esempio non deve essere usato per formulare query reali.", + scope: "Archivio dimostrativo, senza dati reali.\n\n- Un solo insieme di eventi inventati.\n- Identificatori lunghi come `demo_visuale.archivio_dimostrativo_per_verifica_della_formattazione`.\n- Valido esclusivamente per la verifica grafica.", + rationale: "**Scopo della prova:** confrontare paragrafi, elenchi e codice inline.\n\nIl testo deve usare tutta la larghezza del dettaglio e adattarsi a un pannello stretto senza uscire dai suoi bordi.", + question: "Quanti soggetti fittizi compaiono negli eventi dimostrativi?", sql: "", + concepts: ["esempio simulato", "eventi", "soggetti"], dependencies: [], links: [], + }, + { + family: "sql_rule", subject: "FAKE · Regola con elenco e identificatori lunghi", + detail: "### Prima del conteggio\n\nLa regola è completamente simulata. Serve a verificare titoli, spaziatura e un elenco di passi ordinati, senza definire una procedura operativa.\n\n1. Leggere il campo `demo_visuale.identificatore_esteso_dell_evento_dimostrativo`.\n2. Conservare il valore originale per la visualizzazione.\n3. Mostrare il risultato senza salvarlo nell'archivio.\n\n### Casi particolari\n\n- Il numero `0.4` deve restare integro.\n- L'espressione `status IN ('A', 'B');` non deve essere spezzata in paragrafi.\n- Un [collegamento dimostrativo](https://example.com/formatting) deve mantenere testo e destinazione.", + scope: "Solo anteprima dell'interfaccia.\n\nNessuna dipendenza da tabelle reali e nessuna indicizzazione.", + rationale: "Verificare che gli elenchi siano leggibili e che i valori tecnici non vengano alterati.", + question: "", sql: "", concepts: ["formattazione", "SQL simulato"], dependencies: [], links: [], + }, + { + family: "solved_question", subject: "FAKE · Domanda risolta con SQL dimostrativo", + detail: "L'esempio mostra un piccolo insieme di righe letterali, completamente inventate.\n\nIl codice è presentato come testo: nessuna query viene eseguita aprendo questa scheda.", + scope: "Solo tre righe di prova.\n\nNessuna connessione a database o a dati personali.", + rationale: "Il risultato previsto è utile soltanto a verificare la lettura del codice e la larghezza disponibile.", + question: "Quanti eventi fittizi appartengono a ciascuna categoria?", + sql: "-- FAKE: esempio grafico, non eseguito\nWITH demo_visuale(categoria, evento) AS (\n VALUES ('A', 1), ('A', 2), ('B', 3)\n)\nSELECT categoria, COUNT(*) AS numero_eventi_dimostrativi\nFROM demo_visuale\nGROUP BY categoria;", + concepts: ["domanda simulata", "conteggio"], dependencies: [], links: [], + }, + { + family: "explained_error", subject: "FAKE · Errore spiegato e correzione illustrativa", + detail: "**Errore simulato:** un esempio conta le righe dopo un collegamento che moltiplica gli eventi.\n\n**Effetto visivo:** il risultato appare maggiore di quello atteso, pur partendo dagli stessi dati inventati.\n\n**Correzione illustrativa:** confrontare la granularità delle due sorgenti dimostrative prima di scegliere il conteggio.", + scope: "Ambiente di prova grafica.\n\nQuesta scheda non descrive un incidente reale né una regola clinica.", + rationale: "La distinzione fra sintomo, spiegazione e correzione deve essere immediatamente visibile.\n\nI tre paragrafi non devono essere riuniti in un unico blocco di testo.", + question: "Perché il totale dell'esempio è cambiato?", sql: "", + concepts: ["errore simulato", "leggibilità"], dependencies: [], links: [], + }, +]; + +export const memoryFormattingExamples: readonly MemoryCard[] = samples.map((card, index) => ({ + ...card, id: `formatting-example-${index + 1}`, workspace_id: "formatting-preview-only", + origin: "manual", session_id: null, decision_seq: null, indexed: false, + created_at: "2026-09-13T00:00:00Z", updated_at: "2026-09-13T00:00:00Z", revision: "preview-only", +})); diff --git a/frontend/src/viewers/MarkdownView.readable.test.tsx b/frontend/src/viewers/MarkdownView.readable.test.tsx new file mode 100644 index 00000000..745a5fa9 --- /dev/null +++ b/frontend/src/viewers/MarkdownView.readable.test.tsx @@ -0,0 +1,32 @@ +import { render } from "@testing-library/react"; +import { MarkdownView } from "./MarkdownView"; + +test("readable paragraphs preserve text, inline code, links and authored source", () => { + const source = `${"Una regola dimostrativa conserva sempre i dati originali. ".repeat(5)}Si usa \`flag = FALSE; valore >= 0.4\` senza cambiare il codice; ${"Questa parte spiega il criterio senza aggiungere nuove informazioni. ".repeat(5)}Leggere **tutto** e il [documento](https://example.com/a.b).`; + const normal = render(); + const expected = normal.container.textContent; + normal.unmount(); + const view = render(); + expect(view.container.querySelectorAll(".markdown-view > p").length).toBeGreaterThan(2); + // HTML paragraph boundaries add whitespace, but never change the wording. + expect(view.container.textContent?.replace(/\s+/g, " ").trim()).toBe(expected?.replace(/\s+/g, " ").trim()); + expect(view.container.querySelector("code")).toHaveTextContent("flag = FALSE; valore >= 0.4"); + expect(view.container.querySelector("strong")).toHaveTextContent("tutto"); + expect(view.container.querySelector("a")).toHaveAttribute("href", "https://example.com/a.b"); + expect(view.container.querySelector(".markdown-view")).toHaveClass("thot-knowledge-prose"); +}); + +test("leaves existing paragraphs, lists and fenced SQL unchanged", () => { + const sql = `SELECT '${"valore; con punteggiatura. ".repeat(20)}' AS esempio;`; + const source = `Primo paragrafo.\n\nSecondo paragrafo.\n\n- Una voce\n- Altra voce\n\n\`\`\`sql\n${sql}\n\`\`\``; + const view = render(); + expect(view.container.querySelectorAll(".markdown-view > p")).toHaveLength(2); + expect(view.container.querySelectorAll("li")).toHaveLength(2); + expect(view.container.querySelector("pre code")?.textContent).toBe(sql + "\n"); +}); + +test("does not enable long-paragraph reflow for ordinary document viewers", () => { + const view = render(); + expect(view.container.querySelectorAll("p")).toHaveLength(1); + expect(view.container.querySelector(".thot-knowledge-prose")).toBeNull(); +}); diff --git a/frontend/src/viewers/MarkdownView.tsx b/frontend/src/viewers/MarkdownView.tsx index 3e1db88e..1e338f6c 100644 --- a/frontend/src/viewers/MarkdownView.tsx +++ b/frontend/src/viewers/MarkdownView.tsx @@ -4,6 +4,7 @@ import remarkGfm from "remark-gfm"; import type { Components } from "react-markdown"; import { renderMermaid } from "./mermaid"; import { useShell } from "../shell/host/ShellProvider"; +import { readableParagraphs } from "./readableParagraphs"; function MermaidBlock({ code }: { code: string }) { const { theme } = useShell(); @@ -45,10 +46,10 @@ const components: Components = { }, }; -export function MarkdownView({ source }: { source: string }) { +export function MarkdownView({ source, readable = false }: { source: string; readable?: boolean }) { return ( -
      - +
      + {source}
      diff --git a/frontend/src/viewers/readableParagraphs.ts b/frontend/src/viewers/readableParagraphs.ts new file mode 100644 index 00000000..9212f049 --- /dev/null +++ b/frontend/src/viewers/readableParagraphs.ts @@ -0,0 +1,41 @@ +import type { Paragraph, PhrasingContent, Root, RootContent } from "mdast"; + +// Display-only: preserve every inline node and character. Never rewrite source +// files, SQL, links, existing lists or short authored paragraphs. +export function readableParagraphs() { + return (tree: Root) => { + tree.children = tree.children.flatMap(node => { + if (node.type !== "paragraph") return [node]; + const length = node.children.reduce((total, child) => total + ("value" in child ? String(child.value).length : 0), 0); + if (length < 360) return [node]; + const paragraphs: Paragraph[] = []; + let children: PhrasingContent[] = []; + let size = 0; + for (const child of node.children) { + if (child.type !== "text") { + children.push(child); + if ("value" in child) size += String(child.value).length; + continue; + } + // Boundaries must be outside inline code/emphasis/links. Decimal points + // and identifiers cannot match because they have no following space. + let start = 0; + for (const match of child.value.matchAll(/;\s+|[.!?]\s+(?=[A-ZÀ-Ý])/g)) { + const end = match.index! + match[0].length; + if (size + end - start < 180) continue; + children.push({ type: "text", value: child.value.slice(start, end) }); + paragraphs.push({ type: "paragraph", children }); + children = []; + size = 0; + start = end; + } + if (start < child.value.length) { + children.push({ type: "text", value: child.value.slice(start) }); + size += child.value.length - start; + } + } + if (children.length) paragraphs.push({ type: "paragraph", children }); + return paragraphs; + }); + }; +}