feat(evidence): add table-free v3 and design guidance

This commit is contained in:
Codex
2026-08-26 12:15:40 +02:00
parent 38f02cfd08
commit 9d4f994d3e
11 changed files with 983 additions and 67 deletions
+203
View File
@@ -0,0 +1,203 @@
{
"schemaVersion": 2,
"generatedAt": "2026-08-26T10:10:15.926Z",
"title": "Design System: ThothII",
"extensions": {
"colorMeta": {
"instrument-red": {
"role": "primary",
"displayName": "Instrument Red",
"canonical": "oklch(55.87% 0.1881 23.2)",
"tonalRamp": ["oklch(15% 0.07 23.2)", "oklch(28% 0.12 23.2)", "oklch(42% 0.16 23.2)", "oklch(56% 0.1881 23.2)", "oklch(68% 0.17 23.2)", "oklch(78% 0.13 23.2)", "oklch(88% 0.07 23.2)", "oklch(95% 0.03 23.2)"]
},
"instrument-red-hover": {
"role": "primary",
"displayName": "Instrument Red Pressed",
"canonical": "oklch(50.95% 0.1812 24.1)",
"tonalRamp": ["oklch(15% 0.07 24.1)", "oklch(28% 0.12 24.1)", "oklch(42% 0.16 24.1)", "oklch(51% 0.1812 24.1)", "oklch(68% 0.16 24.1)", "oklch(78% 0.12 24.1)", "oklch(88% 0.07 24.1)", "oklch(95% 0.03 24.1)"]
},
"porcelain-background": {
"role": "neutral",
"displayName": "Porcelain Background",
"canonical": "oklch(99.18% 0.0011 17.2)",
"tonalRamp": ["oklch(15% 0.0011 17.2)", "oklch(28% 0.0011 17.2)", "oklch(42% 0.0011 17.2)", "oklch(56% 0.0011 17.2)", "oklch(68% 0.0011 17.2)", "oklch(78% 0.0011 17.2)", "oklch(88% 0.0011 17.2)", "oklch(95% 0.0011 17.2)"]
},
"porcelain-card": {
"role": "neutral",
"displayName": "Porcelain Card",
"canonical": "oklch(99.85% 0.0006 17.2)",
"tonalRamp": ["oklch(15% 0.0006 17.2)", "oklch(28% 0.0006 17.2)", "oklch(42% 0.0006 17.2)", "oklch(56% 0.0006 17.2)", "oklch(68% 0.0006 17.2)", "oklch(78% 0.0006 17.2)", "oklch(88% 0.0006 17.2)", "oklch(95% 0.0006 17.2)"]
},
"warm-surface": {
"role": "neutral",
"displayName": "Warm Surface",
"canonical": "oklch(97.09% 0.0011 17.2)",
"tonalRamp": ["oklch(15% 0.0011 17.2)", "oklch(28% 0.0011 17.2)", "oklch(42% 0.0011 17.2)", "oklch(56% 0.0011 17.2)", "oklch(68% 0.0011 17.2)", "oklch(78% 0.0011 17.2)", "oklch(88% 0.0011 17.2)", "oklch(95% 0.0011 17.2)"]
},
"sunken-surface": {
"role": "neutral",
"displayName": "Sunken Surface",
"canonical": "oklch(94.08% 0.0011 17.2)",
"tonalRamp": ["oklch(15% 0.0011 17.2)", "oklch(28% 0.0011 17.2)", "oklch(42% 0.0011 17.2)", "oklch(56% 0.0011 17.2)", "oklch(68% 0.0011 17.2)", "oklch(78% 0.0011 17.2)", "oklch(88% 0.0011 17.2)", "oklch(95% 0.0011 17.2)"]
},
"warm-graphite": {
"role": "neutral",
"displayName": "Warm Graphite",
"canonical": "oklch(26.78% 0.0097 355.6)",
"tonalRamp": ["oklch(15% 0.0097 355.6)", "oklch(28% 0.0097 355.6)", "oklch(42% 0.0097 355.6)", "oklch(56% 0.0097 355.6)", "oklch(68% 0.008 355.6)", "oklch(78% 0.006 355.6)", "oklch(88% 0.004 355.6)", "oklch(95% 0.002 355.6)"]
},
"muted-graphite": {
"role": "neutral",
"displayName": "Muted Graphite",
"canonical": "oklch(51.33% 0.0088 345.6)",
"tonalRamp": ["oklch(15% 0.0088 345.6)", "oklch(28% 0.0088 345.6)", "oklch(42% 0.0088 345.6)", "oklch(56% 0.0088 345.6)", "oklch(68% 0.007 345.6)", "oklch(78% 0.005 345.6)", "oklch(88% 0.003 345.6)", "oklch(95% 0.002 345.6)"]
},
"quiet-border": {
"role": "neutral",
"displayName": "Quiet Border",
"canonical": "oklch(90.93% 0.0035 354.7)",
"tonalRamp": ["oklch(15% 0.0035 354.7)", "oklch(28% 0.0035 354.7)", "oklch(42% 0.0035 354.7)", "oklch(56% 0.0035 354.7)", "oklch(68% 0.0035 354.7)", "oklch(78% 0.0035 354.7)", "oklch(88% 0.003 354.7)", "oklch(95% 0.002 354.7)"]
},
"success-mint": {
"role": "secondary",
"displayName": "Success Mint",
"canonical": "oklch(75.77% 0.1581 165)",
"tonalRamp": ["oklch(15% 0.06 165)", "oklch(28% 0.1 165)", "oklch(42% 0.14 165)", "oklch(56% 0.1581 165)", "oklch(68% 0.15 165)", "oklch(78% 0.12 165)", "oklch(88% 0.07 165)", "oklch(95% 0.03 165)"]
},
"warning-amber": {
"role": "tertiary",
"displayName": "Warning Amber",
"canonical": "oklch(85.23% 0.1386 78.9)",
"tonalRamp": ["oklch(15% 0.05 78.9)", "oklch(28% 0.09 78.9)", "oklch(42% 0.12 78.9)", "oklch(56% 0.1386 78.9)", "oklch(68% 0.13 78.9)", "oklch(78% 0.1 78.9)", "oklch(88% 0.06 78.9)", "oklch(95% 0.025 78.9)"]
},
"information-blue": {
"role": "tertiary",
"displayName": "Information Blue",
"canonical": "oklch(70.35% 0.1128 221.3)",
"tonalRamp": ["oklch(15% 0.045 221.3)", "oklch(28% 0.075 221.3)", "oklch(42% 0.1 221.3)", "oklch(56% 0.1128 221.3)", "oklch(68% 0.105 221.3)", "oklch(78% 0.08 221.3)", "oklch(88% 0.045 221.3)", "oklch(95% 0.02 221.3)"]
}
},
"typographyMeta": {
"display": {"displayName": "Display", "purpose": "Authentication and exceptional page-level statements only."},
"headline": {"displayName": "Headline", "purpose": "Major page and persisted artifact titles."},
"title": {"displayName": "Title", "purpose": "Panel and document section hierarchy."},
"body": {"displayName": "Body", "purpose": "Operational prose and sustained reading."},
"control": {"displayName": "Control", "purpose": "Buttons, inputs, tabs, and compact actions."},
"label": {"displayName": "Machine Label", "purpose": "Uppercase metadata and machine-oriented micro-labels."}
},
"shadows": [
{"name": "contact", "value": "0 1px 2px oklch(var(--shadow-tint) / 0.05)", "purpose": "Contact shadow for controls and code blocks."},
{"name": "panel", "value": "0 1px 2px oklch(var(--shadow-tint) / 0.05), 0 2px 6px -1px oklch(var(--shadow-tint) / 0.05)", "purpose": "Small structural lift for selected cards."},
{"name": "overlay", "value": "0 2px 4px -2px oklch(var(--shadow-tint) / 0.06), 0 12px 32px -8px oklch(var(--shadow-tint) / 0.1)", "purpose": "Broad low-opacity lift for dialogs and floating layers."}
],
"motion": [
{"name": "control-feedback", "value": "140ms cubic-bezier(0.22, 1, 0.36, 1)", "purpose": "Button hover, focus, and press feedback."},
{"name": "overlay-transition", "value": "100ms ease-out", "purpose": "Dialog fade and scale transitions."},
{"name": "activity-pulse", "value": "1.5s ease-in-out infinite", "purpose": "Live model activity only; disabled for reduced motion."}
],
"breakpoints": [
{"name": "sm", "value": "640px"},
{"name": "lg", "value": "1024px"}
]
},
"components": [
{
"name": "Primary Button",
"kind": "button",
"refersTo": "button-primary",
"description": "The authoritative action for the current workflow step.",
"html": "<button class=\"ds-button-primary\">Confirm review</button>",
"css": ".ds-button-primary { display:inline-flex; align-items:center; justify-content:center; height:32px; padding:0 14px; border:1px solid transparent; border-radius:8px; background:oklch(var(--primary)); color:oklch(var(--primary-foreground)); font:600 14px/1.25 var(--font-sans); letter-spacing:0.005em; box-shadow:var(--shadow-xs); transition:color 140ms cubic-bezier(0.22,1,0.36,1),background-color 140ms cubic-bezier(0.22,1,0.36,1),box-shadow 140ms cubic-bezier(0.22,1,0.36,1),transform 140ms cubic-bezier(0.22,1,0.36,1); } .ds-button-primary:hover { background:oklch(var(--primary-hover)); } .ds-button-primary:focus-visible { outline:3px solid oklch(var(--ring)/0.25); outline-offset:2px; } .ds-button-primary:active { transform:scale(0.97); box-shadow:none; }"
},
{
"name": "Outline Button",
"kind": "button",
"refersTo": "button-secondary",
"description": "A compact secondary action that preserves the primary action hierarchy.",
"html": "<button class=\"ds-button-outline\">Inspect details</button>",
"css": ".ds-button-outline { display:inline-flex; align-items:center; justify-content:center; height:32px; padding:0 14px; border:1px solid oklch(var(--border)); border-radius:8px; background:oklch(var(--card)); color:oklch(var(--foreground)); font:600 14px/1.25 var(--font-sans); box-shadow:var(--shadow-xs); transition:background-color 140ms cubic-bezier(0.22,1,0.36,1),transform 140ms cubic-bezier(0.22,1,0.36,1); } .ds-button-outline:hover { background:oklch(var(--muted)); } .ds-button-outline:focus-visible { outline:3px solid oklch(var(--ring)/0.25); outline-offset:2px; } .ds-button-outline:active { transform:scale(0.97); box-shadow:none; }"
},
{
"name": "Status Badge",
"kind": "chip",
"refersTo": "badge-primary",
"description": "A compact state label that always carries readable text.",
"html": "<span class=\"ds-status-badge\">Ready for review</span>",
"css": ".ds-status-badge { display:inline-flex; align-items:center; height:20px; padding:2px 8px; border:1px solid transparent; border-radius:6px; background:oklch(var(--primary)); color:oklch(var(--primary-foreground)); font:600 12px/1.25 var(--font-sans); white-space:nowrap; } .ds-status-badge:focus-visible { outline:3px solid oklch(var(--ring)/0.5); outline-offset:2px; }"
},
{
"name": "Text Field",
"kind": "input",
"refersTo": "input-default",
"description": "A readable operational field with an explicit focus state.",
"html": "<input class=\"ds-text-field\" value=\"Fascia pediatrica\" aria-label=\"Session name\">",
"css": ".ds-text-field { width:280px; height:40px; padding:0 12px; border:1px solid oklch(var(--input)); border-radius:8px; background:oklch(var(--background)); color:oklch(var(--foreground)); font:400 14px/1.5 var(--font-sans); outline:none; } .ds-text-field:hover { border-color:oklch(var(--muted-foreground)/0.65); } .ds-text-field:focus-visible { border-color:oklch(var(--ring)); box-shadow:0 0 0 3px oklch(var(--ring)/0.25); } .ds-text-field:disabled { opacity:0.5; cursor:not-allowed; }"
},
{
"name": "Work Card",
"kind": "card",
"refersTo": "card-default",
"description": "A single-level container for a coherent review surface.",
"html": "<section class=\"ds-work-card\"><h3>Schema linking</h3><p>Review the linked tables and columns before continuing.</p></section>",
"css": ".ds-work-card { width:320px; padding:16px; border:1px solid oklch(var(--border)/0.7); border-radius:12px; background:oklch(var(--card)); color:oklch(var(--card-foreground)); box-shadow:var(--shadow-sm); } .ds-work-card h3 { margin:0 0 8px; font:500 16px/1.35 var(--font-heading); letter-spacing:-0.01em; } .ds-work-card p { margin:0; color:oklch(var(--muted-foreground)); font:400 14px/1.6 var(--font-sans); } .ds-work-card:focus-within { outline:3px solid oklch(var(--ring)/0.25); outline-offset:2px; }"
},
{
"name": "Session Navigation Item",
"kind": "nav",
"description": "A dense session row with restrained hover and active hierarchy.",
"html": "<button class=\"ds-session-item\"><span class=\"ds-session-dot\"></span><span><strong>Patient cohorts</strong><small>Schema linking</small></span></button>",
"css": ".ds-session-item { display:flex; width:260px; align-items:center; gap:8px; padding:4px 8px; border:0; border-radius:8px; background:transparent; color:oklch(var(--foreground)); text-align:left; font-family:var(--font-sans); transition:background-color 140ms cubic-bezier(0.22,1,0.36,1); } .ds-session-item:hover,.ds-session-item[aria-current=\"page\"] { background:oklch(var(--accent)); } .ds-session-item:focus-visible { outline:2px solid oklch(var(--ring)/0.4); outline-offset:1px; } .ds-session-dot { width:6px; height:6px; flex:none; border-radius:9999px; background:oklch(var(--success)); } .ds-session-item strong,.ds-session-item small { display:block; } .ds-session-item strong { font-size:13px; font-weight:600; } .ds-session-item small { margin-top:2px; color:oklch(var(--muted-foreground)); font-size:11px; }"
},
{
"name": "Curated Evidence Document",
"kind": "custom",
"description": "The table-free reading hierarchy for persisted evidence.",
"html": "<article class=\"ds-evidence\"><h2>Fascia pediatrica</h2><div class=\"ds-evidence-summary\"><strong>Dominio</strong> · Italiano<br><span>Scopi: Disambiguazione · Generazione SQL</span></div><h3>Ambito di applicazione</h3><ul><li>fascia pediatrica</li><li>paziente minore</li></ul><h3>Regola</h3><p>La fascia pediatrica comprende i pazienti con età inferiore a 18 anni.</p><details><summary>Dettagli tecnici e provenienza</summary><code>evidence:fascia-pediatrica</code></details></article>",
"css": ".ds-evidence { max-width:70ch; color:oklch(var(--foreground)); font:400 15px/1.65 var(--font-sans); } .ds-evidence h2,.ds-evidence h3 { font-family:var(--font-heading); letter-spacing:-0.01em; } .ds-evidence h2 { margin:0 0 16px; font-size:24px; } .ds-evidence h3 { margin:24px 0 8px; font-size:18px; } .ds-evidence-summary { padding:12px 14px; border:1px solid oklch(var(--border)); border-radius:8px; background:oklch(var(--muted)); color:oklch(var(--muted-foreground)); } .ds-evidence-summary strong { color:oklch(var(--foreground)); } .ds-evidence ul { padding-left:20px; } .ds-evidence details { margin-top:24px; padding:10px 12px; border:1px solid oklch(var(--border)); border-radius:8px; background:oklch(var(--card)); } .ds-evidence summary { cursor:pointer; font-weight:600; } .ds-evidence code { font-family:var(--font-mono); }"
}
],
"narrative": {
"northStar": "The Clinical Workbench",
"overview": "ThothII should feel like a well-kept clinical workbench: warm enough for sustained reading, exact enough for consequential review, and quiet enough that evidence, state, and decisions remain in the foreground. The visual system is calm, precise, and trustworthy. It uses familiar product patterns, restrained color, and deliberate density instead of decorative spectacle.\n\nThe primary physical scene is an analyst reviewing persisted evidence and SQL on a large monitor in a well-lit working environment. This makes the warm light theme the default. The supported dark theme serves lower-light work without becoming a separate neon aesthetic. Both themes preserve the same hierarchy and semantic roles.\n\nThe system rejects generic SaaS ornament, conspicuous ripples, bounce or elastic motion, long choreographed transitions, and effects that compete with the analytical task. Controls should feel disciplined and tactile, never playful, sluggish, or visually unstable.",
"keyCharacteristics": [
"Warm, restrained surfaces with one scarce red accent.",
"Editorial headings paired with highly legible operational body text.",
"Dense information organized through hierarchy, rhythm, and progressive disclosure.",
"Persisted artifacts and reviewer decisions presented as the visual source of truth.",
"Fast state feedback with reduced-motion parity."
],
"rules": [
{"name": "The Workbench Rule", "body": "Every visual element must support inspection, action, state, or provenance. Decoration without an operational purpose is forbidden.", "section": "overview"},
{"name": "The Persisted Truth Rule", "body": "Persisted artifacts and reviewer decisions receive stronger hierarchy than transient model narration.", "section": "overview"},
{"name": "The Density with Rhythm Rule", "body": "Preserve information density, but vary spacing between groups so users can scan structure without adding nested containers.", "section": "overview"},
{"name": "The One Voice Rule", "body": "Instrument Red should occupy no more than roughly ten percent of a screen. Its rarity is what makes it authoritative.", "section": "colors"},
{"name": "The State Has a Name Rule", "body": "Success, warning, information, and destructive colors are reserved for their named states. Color is never the only state indicator.", "section": "colors"},
{"name": "The Three Registers Rule", "body": "Serif means authority, sans means interaction and reading, mono means machine identity. Do not exchange these roles for novelty.", "section": "typography"},
{"name": "The Read Once Rule", "body": "A heading, label, and body must be distinguishable on first glance through size and weight. Do not repeat headings in explanatory copy.", "section": "typography"},
{"name": "The Flat by Default Rule", "body": "A resting surface has no shadow unless it is physically above another surface. If every panel floats, none of them has hierarchy.", "section": "elevation"},
{"name": "The Borders Structure, Shadows Elevate Rule", "body": "Never use shadow as a substitute for grouping or a border as a decorative accent.", "section": "elevation"},
{"name": "The Review Surface Rule", "body": "The visible Markdown must be readable without understanding the machine contract. Technical metadata belongs in progressive disclosure, not above the title.", "section": "components"}
],
"dos": [
"Do make every state change unmistakable without interrupting flow.",
"Do use Instrument Red only for primary action, current selection, focus identity, or explicit destructive meaning.",
"Do preserve information density with headings, rhythm, and progressive disclosure.",
"Do keep keyboard focus explicit and pair color with text, shape, icon, or position.",
"Do respect prefers-reduced-motion while preserving immediate non-kinetic feedback.",
"Do use English for interface chrome and the workspace language for persisted document content.",
"Do render curated metadata and scope as Markdown prose or lists, never as a frontmatter table."
],
"donts": [
"Don't add generic SaaS ornament, conspicuous ripples, bounce or elastic motion, long choreographed transitions, or effects that compete with the analytical task.",
"Don't make controls feel playful, sluggish, or visually unstable.",
"Don't use gradient text, decorative glassmorphism, or full-saturation accents on inactive states.",
"Don't use a colored side stripe greater than one pixel on cards, callouts, list items, or blockquotes. Use a full border, tonal background, icon, or heading instead.",
"Don't nest cards or wrap every section in a container.",
"Don't use a modal before exhausting inline or progressive alternatives.",
"Don't use tables for applies_to, metadata, enum values, or other one-dimensional content.",
"Don't use color as the sole carrier of success, warning, error, selection, or progress.",
"Don't use display typography for buttons, labels, or data.",
"Don't add em dashes to interface copy. Use commas, colons, semicolons, or parentheses."
]
}
}
+325
View File
@@ -0,0 +1,325 @@
---
name: ThothII
description: "A calm, precise clinical analytics workbench for traceable and reviewable SQL workflows."
colors:
instrument-red: "oklch(55.87% 0.1881 23.2)"
instrument-red-hover: "oklch(50.95% 0.1812 24.1)"
porcelain-background: "oklch(99.18% 0.0011 17.2)"
porcelain-card: "oklch(99.85% 0.0006 17.2)"
warm-surface: "oklch(97.09% 0.0011 17.2)"
sunken-surface: "oklch(94.08% 0.0011 17.2)"
warm-graphite: "oklch(26.78% 0.0097 355.6)"
muted-graphite: "oklch(51.33% 0.0088 345.6)"
quiet-border: "oklch(90.93% 0.0035 354.7)"
success-mint: "oklch(75.77% 0.1581 165)"
warning-amber: "oklch(85.23% 0.1386 78.9)"
information-blue: "oklch(70.35% 0.1128 221.3)"
typography:
display:
fontFamily: "Fraunces, Source Serif Pro, Georgia, Times New Roman, serif"
fontSize: "3rem"
fontWeight: 600
lineHeight: 1.03
letterSpacing: "-0.025em"
headline:
fontFamily: "Fraunces, Source Serif Pro, Georgia, Times New Roman, serif"
fontSize: "1.875rem"
fontWeight: 600
lineHeight: 1.15
letterSpacing: "-0.015em"
title:
fontFamily: "Fraunces, Source Serif Pro, Georgia, Times New Roman, serif"
fontSize: "1.2rem"
fontWeight: 600
lineHeight: 1.25
letterSpacing: "-0.01em"
body:
fontFamily: "Manrope, -apple-system, BlinkMacSystemFont, Segoe UI, system-ui, Arial, sans-serif"
fontSize: "0.9375rem"
fontWeight: 400
lineHeight: 1.65
letterSpacing: "normal"
control:
fontFamily: "Manrope, -apple-system, BlinkMacSystemFont, Segoe UI, system-ui, Arial, sans-serif"
fontSize: "0.875rem"
fontWeight: 600
lineHeight: 1.25
letterSpacing: "0.005em"
label:
fontFamily: "ui-monospace, SF Mono, Cascadia Code, Menlo, Consolas, monospace"
fontSize: "0.6875rem"
fontWeight: 600
lineHeight: 1.25
letterSpacing: "0.06em"
rounded:
xs: "4px"
sm: "6px"
md: "8px"
lg: "12px"
xl: "16px"
full: "9999px"
spacing:
xs: "4px"
sm: "8px"
md: "16px"
lg: "24px"
xl: "32px"
components:
button-primary:
backgroundColor: "{colors.instrument-red}"
textColor: "{colors.porcelain-background}"
typography: "{typography.control}"
rounded: "{rounded.md}"
padding: "0 14px"
height: "32px"
button-primary-hover:
backgroundColor: "{colors.instrument-red-hover}"
textColor: "{colors.porcelain-background}"
typography: "{typography.control}"
rounded: "{rounded.md}"
padding: "0 14px"
height: "32px"
button-secondary:
backgroundColor: "{colors.porcelain-card}"
textColor: "{colors.warm-graphite}"
typography: "{typography.control}"
rounded: "{rounded.md}"
padding: "0 14px"
height: "32px"
input-default:
backgroundColor: "{colors.porcelain-background}"
textColor: "{colors.warm-graphite}"
typography: "{typography.body}"
rounded: "{rounded.md}"
padding: "0 12px"
height: "40px"
card-default:
backgroundColor: "{colors.porcelain-card}"
textColor: "{colors.warm-graphite}"
rounded: "{rounded.lg}"
padding: "16px"
badge-primary:
backgroundColor: "{colors.instrument-red}"
textColor: "{colors.porcelain-background}"
typography: "{typography.control}"
rounded: "{rounded.sm}"
padding: "2px 8px"
height: "20px"
---
# Design System: ThothII
## Overview
**Creative North Star: "The Clinical Workbench"**
ThothII should feel like a well-kept clinical workbench: warm enough for sustained reading, exact
enough for consequential review, and quiet enough that evidence, state, and decisions remain in the
foreground. The visual system is calm, precise, and trustworthy. It uses familiar product patterns,
restrained color, and deliberate density instead of decorative spectacle.
The primary physical scene is an analyst reviewing persisted evidence and SQL on a large monitor in
a well-lit working environment. This makes the warm light theme the default. The supported dark
theme serves lower-light work without becoming a separate neon aesthetic. Both themes preserve the
same hierarchy and semantic roles.
The system rejects generic SaaS ornament, conspicuous ripples, bounce or elastic motion, long
choreographed transitions, and effects that compete with the analytical task. Controls should feel
disciplined and tactile, never playful, sluggish, or visually unstable.
**Key Characteristics:**
- Warm, restrained surfaces with one scarce red accent.
- Editorial headings paired with highly legible operational body text.
- Dense information organized through hierarchy, rhythm, and progressive disclosure.
- Persisted artifacts and reviewer decisions presented as the visual source of truth.
- Fast state feedback with reduced-motion parity.
**The Workbench Rule.** Every visual element must support inspection, action, state, or provenance.
Decoration without an operational purpose is forbidden.
**The Persisted Truth Rule.** Persisted artifacts and reviewer decisions receive stronger hierarchy
than transient model narration.
**The Density with Rhythm Rule.** Preserve information density, but vary spacing between groups so
users can scan structure without adding nested containers.
## Colors
The palette combines warm porcelain surfaces, warm graphite text, and an instrument red used only
for action, focus, and important state. OKLCH values in the frontmatter are normative because the
frontend uses OKLCH tokens directly.
### Primary
- **Instrument Red** (`instrument-red`): primary actions, current selection, focus identity, and
destructive meaning where the context already makes the action explicit.
- **Instrument Red Pressed** (`instrument-red-hover`): hover and active emphasis for the primary
action family.
### Neutral
- **Porcelain Background** (`porcelain-background`): the main canvas.
- **Porcelain Card** (`porcelain-card`): lifted panels, cards, and popovers.
- **Warm Surface** (`warm-surface`): sidebars, secondary controls, and muted regions.
- **Sunken Surface** (`sunken-surface`): selected rows, quiet emphasis, and inset regions.
- **Warm Graphite** (`warm-graphite`): primary text and high-confidence labels.
- **Muted Graphite** (`muted-graphite`): descriptions, timestamps, and secondary metadata.
- **Quiet Border** (`quiet-border`): structural boundaries, input outlines, and dividers.
### Semantic
- **Success Mint** (`success-mint`): completed and ready states.
- **Warning Amber** (`warning-amber`): waiting, attention, and in-progress states.
- **Information Blue** (`information-blue`): informational state when red would imply action.
The dark theme keeps the same semantic mapping with neutral near-black surfaces and a slightly
lighter red accent. Do not introduce a second visual identity for dark mode.
**The One Voice Rule.** Instrument Red should occupy no more than roughly ten percent of a screen.
Its rarity is what makes it authoritative.
**The State Has a Name Rule.** Success, warning, information, and destructive colors are reserved
for their named states. Color is never the only state indicator.
## Typography
**Display Font:** Fraunces, with Source Serif Pro, Georgia, and Times New Roman fallbacks
**Body Font:** Manrope, with native system sans-serif fallbacks
**Label/Mono Font:** SF Mono or Cascadia Code, with Menlo and Consolas fallbacks
**Character:** Fraunces gives persisted artifacts and key headings editorial authority. Manrope
keeps dense controls and prose calm and readable. The mono register separates machine identity,
metadata, SQL, identifiers, and micro-labels from natural-language content.
### Hierarchy
- **Display** (600, `3rem`, `1.03`): authentication and exceptional page-level statements only.
- **Headline** (600, `1.875rem`, `1.15`): major page or artifact titles.
- **Title** (600, `1.2rem`, `1.25`): panel and document section hierarchy.
- **Body** (400, `0.9375rem`, `1.65`): operational prose, with a target line length of 65 to 75
characters where the surface controls width.
- **Control** (600, `0.875rem`, `1.25`): buttons, inputs, tabs, and compact actions.
- **Label** (600, `0.6875rem`, `0.06em` tracking): uppercase micro-labels, state metadata, and panel
headers. Labels use the mono family.
Typography uses fixed sizes. Responsive changes happen at structural breakpoints, not through fluid
type scaling. Numeric data and identifiers use tabular numerals where comparison matters.
**The Three Registers Rule.** Serif means authority, sans means interaction and reading, mono means
machine identity. Do not exchange these roles for novelty.
**The Read Once Rule.** A heading, label, and body must be distinguishable on first glance through
size and weight. Do not repeat headings in explanatory copy.
## Elevation
The system is flat by default and layered when necessary. Borders mark structure. Warm, diffuse
shadows mark actual elevation for popovers, dialogs, and selected containers. Tonal layering should
solve most hierarchy before a shadow is introduced.
### Shadow Vocabulary
- **Contact Shadow** (`--shadow-xs`): a one-pixel contact shadow for controls and code blocks.
- **Panel Shadow** (`--shadow-sm`): a small two-stage shadow for cards that need separation from the
canvas.
- **Overlay Shadow** (`--shadow-md`): a broad, low-opacity shadow for dialogs and floating layers.
Focus uses an explicit three-pixel ring. Waiting-for-input state may use a success-tinted ring, but
must retain a textual or structural cue. Motion for button state changes lasts `140ms` with
`cubic-bezier(0.22, 1, 0.36, 1)`. Dialog transitions last `100ms`. Activity pulses may run at
`1.5s`, and must be disabled under `prefers-reduced-motion`.
**The Flat by Default Rule.** A resting surface has no shadow unless it is physically above another
surface. If every panel floats, none of them has hierarchy.
**The Borders Structure, Shadows Elevate Rule.** Never use shadow as a substitute for grouping or a
border as a decorative accent.
## Components
Components are familiar, compact, and state-complete. Every interactive primitive must define
default, hover, focus, active, disabled, loading, and error behavior where those states apply.
### Buttons
- **Shape:** gently curved rectangle (`8px`) with a one-pixel transparent or structural border.
- **Primary:** Instrument Red, porcelain text, `32px` default height, and `14px` horizontal padding.
- **Hover / Focus:** shift to Instrument Red Pressed; show a three-pixel focus ring at 25 percent
opacity. Active state scales to `0.97` for `140ms` and removes elevation.
- **Secondary / Outline:** porcelain card surface, Quiet Border, Warm Graphite text, and a Warm
Surface hover.
- **Ghost:** transparent at rest, Warm Surface on hover. Use only where surrounding structure makes
the hit target obvious.
### Badges and Status Indicators
- **Style:** compact (`20px` height), gently curved (`6px`), and semibold.
- **State:** pair semantic color with text, icon, or position. A colored dot alone is insufficient
when the state affects workflow decisions.
### Cards and Containers
- **Corner Style:** softly rounded (`12px`), with `16px` default internal padding.
- **Background:** Porcelain Card over Porcelain Background or Warm Surface.
- **Shadow Strategy:** Panel Shadow only when the card must read as elevated.
- **Border:** one-pixel Quiet Border at partial opacity.
- **Nesting:** nested cards are forbidden. Use headings, dividers, spacing, or tonal regions.
### Inputs and Fields
- **Style:** `40px` height, `8px` corners, Porcelain Background, Quiet Border, and Manrope body text.
- **Focus:** three-pixel Instrument Red ring with a clear border shift.
- **Error / Disabled:** errors combine destructive color with explanatory text; disabled controls
retain readable contrast and use 50 percent opacity.
### Navigation
- **Style:** compact session rows use `8px` corners and restrained vertical padding.
- **Default / Hover / Active:** transparent at rest, Sunken Surface on hover, and the same surface
with stronger text weight when active.
- **Responsive:** collapse navigation structurally at the application breakpoint. Do not shrink
labels into illegibility.
### Curated Evidence Documents
Curated evidence follows a fixed reading order: title, compact type and purpose summary, scope,
typed content, supporting excerpts, review items, then collapsed technical provenance. Machine
metadata stays in invisible comments so GitHub Preview shows only the reviewable document.
`applies_to` is rendered as “Ambito di applicazione” with separate bullet lists for concepts,
tables, and columns. Enum values also use lists. Tables are forbidden for metadata, scope, or any
one-dimensional collection; reserve tables for genuinely two-dimensional datasets. Long machine
identifiers use inline code. SQL uses fenced code. Supporting excerpts use blockquotes.
**The Review Surface Rule.** The visible Markdown must be readable without understanding the
machine contract. Technical metadata belongs in progressive disclosure, not above the title.
## Do's and Don'ts
### Do:
- **Do** make every state change unmistakable without interrupting flow.
- **Do** use Instrument Red only for primary action, current selection, focus identity, or explicit
destructive meaning.
- **Do** preserve information density with headings, rhythm, and progressive disclosure.
- **Do** keep keyboard focus explicit and pair color with text, shape, icon, or position.
- **Do** respect `prefers-reduced-motion` while preserving immediate non-kinetic feedback.
- **Do** use English for interface chrome and the workspace language for persisted document content.
- **Do** render curated metadata and scope as Markdown prose or lists, never as a frontmatter table.
### Don't:
- **Don't** add generic SaaS ornament, conspicuous ripples, bounce or elastic motion, long
choreographed transitions, or effects that compete with the analytical task.
- **Don't** make controls feel playful, sluggish, or visually unstable.
- **Don't** use gradient text, decorative glassmorphism, or full-saturation accents on inactive
states.
- **Don't** use a colored side stripe greater than one pixel on cards, callouts, list items, or
blockquotes. Use a full border, tonal background, icon, or heading instead.
- **Don't** nest cards or wrap every section in a container.
- **Don't** use a modal before exhausting inline or progressive alternatives.
- **Don't** use tables for `applies_to`, metadata, enum values, or other one-dimensional content.
- **Don't** use color as the sole carrier of success, warning, error, selection, or progress.
- **Don't** use display typography for buttons, labels, or data.
- **Don't** add em dashes to interface copy. Use commas, colons, semicolons, or parentheses.
+5 -4
View File
@@ -25,10 +25,11 @@ review gates and keeps the live transcript in memory. See
The evidence restructuring and PSD migration completed real acceptance on 2026-08-25. The evidence restructuring and PSD migration completed real acceptance on 2026-08-25.
- The curated PSD revision contains 35 approved Evidence units and 60 review items. - The curated PSD revision contains 35 approved Evidence units and 60 review items.
- The PSD authoring clone currently has all 35 units migrated locally to Curated unit schema v2: - The PSD authoring repository published all 35 units using Curated unit schema v2. A schema v3
short YAML metadata plus a typed, human-readable Markdown body. The changes remain pending a table-free presentation is now available in the authoring flow: hidden canonical metadata,
curator commit/publication. `tht evidence migrate <workspace-root>` performs the deterministic wrapping Markdown scope lists, list-based enum values, and collapsed technical provenance.
v1-to-v2 rewrite without model calls. `tht evidence migrate <workspace-root>` performs the deterministic v1/v2-to-v3 rewrite without
model calls. The 35-unit PSD v3 migration is currently local and pending commit/publication.
- The accepted snapshot is - The accepted snapshot is
`psd-clinical-675990d90eae51da6f2bd51b1ae2609f245772ef-snapshot`. `psd-clinical-675990d90eae51da6f2bd51b1ae2609f245772ef-snapshot`.
- The active generation is `gen:f968b3bd7a553dbfef3cf47093698f2bc7f95f11`. - The active generation is `gen:f968b3bd7a553dbfef3cf47093698f2bc7f95f11`.
+10 -8
View File
@@ -50,15 +50,17 @@ traceability but never acquired by v2 runtime preprocessing.
### Curated unit representation ### Curated unit representation
The `schema_version` inside each `curated/**/*.md` file is distinct from the workspace descriptor The `schema_version` inside each `curated/**/*.md` file is distinct from the workspace descriptor
version above. Unit schema v1 stores the complete typed unit in YAML frontmatter and remains version above. Unit schema v1 stores the complete typed unit in YAML frontmatter. Unit schema v2
readable for compatibility. Unit schema v2 keeps short metadata in frontmatter and stores the keeps short metadata in frontmatter and stores the typed payload in the body. Both remain readable
typed payload, supporting excerpts, and review items in a deterministic Markdown body. for compatibility.
V2 bodies use headings, paragraphs, code lists, enum tables, fenced SQL, and blockquotes according Unit schema v3 stores canonical machine metadata in an invisible `tht:metadata` comment and renders
to the Evidence kind. Invisible `tht:` comments delimit typed fields. Parsers must reject missing, the complete review surface as deterministic Markdown. It uses headings, paragraphs, wrapping
duplicate, unknown, or unstructured body content; they must never silently ignore it. Newly lists, fenced SQL, blockquotes, and a collapsed technical-details block. It never emits YAML
prepared units use v2. `tht evidence migrate <workspace-root>` upgrades existing v1 units locally frontmatter or Markdown tables. Invisible `tht:` comments delimit typed fields. Parsers must reject
without a model call, commit, publication, or semantic change. missing, duplicate, unknown, desynchronized, or unstructured body content; they must never silently
ignore it. Newly prepared units use v3. `tht evidence migrate <workspace-root>` upgrades existing
v1 and v2 units locally without a model call, commit, publication, or semantic change.
### Example: filesystem ### Example: filesystem
+29 -23
View File
@@ -48,28 +48,26 @@ HTTP and S3 are separate adapters. They do not use the filesystem structure `sou
## What a curated unit must contain ## What a curated unit must contain
Canonical Curated Evidence v2 keeps short machine metadata in YAML frontmatter and renders the Canonical Curated Evidence v3 hides canonical machine metadata in an HTML comment and renders the
reviewable content as real Markdown. The body layout is deterministic for each Evidence kind: whole review surface as real Markdown. GitHub therefore shows no frontmatter table. The body layout
prose uses sections and paragraphs, identifiers use code lists, enum values use tables, formulas is deterministic for each Evidence kind: prose uses sections and paragraphs, scopes and enum values
use fenced SQL, supporting excerpts use blockquotes, and unresolved review items use dedicated use wrapping lists, formulas use fenced SQL, supporting excerpts use blockquotes, and unresolved
blocks. review items use dedicated blocks.
```markdown ```markdown
--- <!-- tht:metadata:<canonical metadata> -->
schema_version: 2
id: evidence:fascia-pediatrica
title: Fascia pediatrica
kind: domain
purposes:
- disambiguation
language: it
provenance:
source_file: source/domain/paziente.md
source_sha256: sha256:0000000000000000000000000000000000000000000000000000000000000000
---
# Fascia pediatrica # Fascia pediatrica
> **Dominio** · Italiano
>
> **Scopi:** Disambiguazione
## Ambito di applicazione
### Concetti
- fascia pediatrica
## Regola ## Regola
La fascia pediatrica comprende i pazienti con età inferiore a 18 anni. La fascia pediatrica comprende i pazienti con età inferiore a 18 anni.
@@ -77,12 +75,20 @@ La fascia pediatrica comprende i pazienti con età inferiore a 18 anni.
## Estratti di supporto ## Estratti di supporto
> I pazienti sotto i 18 anni sono pediatrici. > I pazienti sotto i 18 anni sono pediatrici.
<details>
<summary>Dettagli tecnici e provenienza</summary>
- **ID:** `evidence:fascia-pediatrica`
- **File sorgente:** `source/domain/paziente.md`
</details>
``` ```
The actual files also contain invisible `tht:` comments delimiting typed fields. Curators edit the The actual files contain invisible `tht:` comments for canonical metadata and typed-field
visible Markdown between those markers; removing or duplicating markers makes validation fail boundaries. Removing, duplicating, or desynchronizing them makes validation fail closed instead of
closed instead of silently ignoring content. V1 files containing only frontmatter remain readable silently ignoring content. Unit schemas v1 and v2 remain readable for compatibility, but newly
for compatibility, but newly prepared units use v2. prepared units use v3.
Curated units must be atomic, readable by a second reviewer, and supported by the source. Curated units must be atomic, readable by a second reviewer, and supported by the source.
Provenance references must lead back to the original file and the passage that supports the claim. Provenance references must lead back to the original file and the passage that supports the claim.
@@ -162,7 +168,7 @@ tht evidence prepare <workspace-root>
# Reprocess all sources with the installed pipeline. # Reprocess all sources with the installed pipeline.
tht evidence prepare <workspace-root> --upgrade tht evidence prepare <workspace-root> --upgrade
# Rewrite legacy v1 units as readable v2 Markdown without model calls. # Rewrite legacy v1/v2 units as table-free v3 Markdown without model calls.
tht evidence migrate <workspace-root> tht evidence migrate <workspace-root>
# Validate structure, manifest, links, and review items. # Validate structure, manifest, links, and review items.
+1 -1
View File
@@ -121,7 +121,7 @@ def pipeline(tmp_path, source, *, embedder=None, vectors=None, model="model-a",
def test_pipeline_embeds_validated_curated_evidence_as_semantic_fragments(tmp_path): def test_pipeline_embeds_validated_curated_evidence_as_semantic_fragments(tmp_path):
evidence = CuratedEvidence.model_validate( evidence = CuratedEvidence.model_validate(
{ {
"schema_version": 2, "schema_version": 3,
"id": "evidence:fascia-pediatrica", "id": "evidence:fascia-pediatrica",
"title": "Fascia pediatrica", "title": "Fascia pediatrica",
"kind": "formula", "kind": "formula",
+24 -4
View File
@@ -350,12 +350,12 @@ def test_prepare_changed_source_uses_one_model_call_and_applies_a_valid_batch(tm
assert restructurer.requests[0].previous_units[0].id == "evidence:fascia-pediatrica" assert restructurer.requests[0].previous_units[0].id == "evidence:fascia-pediatrica"
curated_path = tmp_path / "evidence" / "curated" / "domain" / "fascia-pediatrica.md" curated_path = tmp_path / "evidence" / "curated" / "domain" / "fascia-pediatrica.md"
curated = load_curated_tree(tmp_path / "evidence" / "curated")[0] curated = load_curated_tree(tmp_path / "evidence" / "curated")[0]
assert curated.schema_version == 2 assert curated.schema_version == 3
assert "# Fascia pediatrica\n" in curated_path.read_text(encoding="utf-8") assert "# Fascia pediatrica\n" in curated_path.read_text(encoding="utf-8")
assert validate_workspace_evidence(tmp_path).publishable is True assert validate_workspace_evidence(tmp_path).publishable is True
def test_migrate_workspace_evidence_rewrites_v1_units_without_a_model_call(tmp_path): def test_migrate_workspace_evidence_rewrites_v1_units_as_v3_without_a_model_call(tmp_path):
source_text = "I pazienti sotto i 18 anni sono pediatrici." source_text = "I pazienti sotto i 18 anni sono pediatrici."
_write_workspace(tmp_path, _evidence(source_text), source_text) _write_workspace(tmp_path, _evidence(source_text), source_text)
@@ -365,7 +365,7 @@ def test_migrate_workspace_evidence_rewrites_v1_units_without_a_model_call(tmp_p
migrated = load_curated_tree(tmp_path / "evidence" / "curated")[0] migrated = load_curated_tree(tmp_path / "evidence" / "curated")[0]
assert report.migrated == ("evidence:fascia-pediatrica",) assert report.migrated == ("evidence:fascia-pediatrica",)
assert report.unchanged == () assert report.unchanged == ()
assert migrated.schema_version == 2 assert migrated.schema_version == 3
assert migrated.payload.rule == "La fascia pediatrica comprende i minori." assert migrated.payload.rule == "La fascia pediatrica comprende i minori."
assert "## Regola\n\nLa fascia pediatrica comprende i minori." in curated_path.read_text( assert "## Regola\n\nLa fascia pediatrica comprende i minori." in curated_path.read_text(
encoding="utf-8", encoding="utf-8",
@@ -373,6 +373,26 @@ def test_migrate_workspace_evidence_rewrites_v1_units_without_a_model_call(tmp_p
assert report.findings == () assert report.findings == ()
def test_migrate_workspace_evidence_rewrites_v2_units_as_table_free_v3(tmp_path):
source_text = "I pazienti sotto i 18 anni sono pediatrici."
evidence = _evidence(source_text).model_copy(update={"schema_version": 2})
_write_workspace(tmp_path, evidence, source_text)
first = migrate_workspace_evidence(tmp_path, git_status=lambda _: ())
second = migrate_workspace_evidence(tmp_path, git_status=lambda _: ())
curated_path = tmp_path / "evidence" / "curated" / "domain" / "fascia-pediatrica.md"
text = curated_path.read_text(encoding="utf-8")
migrated = load_curated_tree(tmp_path / "evidence" / "curated")[0]
assert first.migrated == ("evidence:fascia-pediatrica",)
assert first.unchanged == ()
assert second.migrated == ()
assert second.unchanged == ("evidence:fascia-pediatrica",)
assert migrated.schema_version == 3
assert text.startswith("<!-- tht:metadata:")
assert not any(line.startswith("|") for line in text.splitlines())
def test_migrate_workspace_evidence_rejects_dirty_curated_files_in_a_nested_workspace(tmp_path): def test_migrate_workspace_evidence_rejects_dirty_curated_files_in_a_nested_workspace(tmp_path):
subprocess.run(["git", "init", "--quiet", str(tmp_path)], check=True) subprocess.run(["git", "init", "--quiet", str(tmp_path)], check=True)
workspace_root = tmp_path / "psd-clinical" workspace_root = tmp_path / "psd-clinical"
@@ -546,7 +566,7 @@ def test_prepare_marks_an_omitted_prior_unit_for_human_review(tmp_path):
"supporting_excerpt_missing", "unresolved_review_item", "supporting_excerpt_missing", "unresolved_review_item",
] ]
retained = load_curated_tree(tmp_path / "evidence" / "curated")[0] retained = load_curated_tree(tmp_path / "evidence" / "curated")[0]
assert retained.schema_version == 2 assert retained.schema_version == 3
assert retained.review_items[0].code == "source_no_longer_supports_unit" assert retained.review_items[0].code == "source_no_longer_supports_unit"
+73
View File
@@ -221,6 +221,79 @@ def _domain_evidence_v2() -> CuratedEvidence:
}) })
def _domain_evidence_v3() -> CuratedEvidence:
return CuratedEvidence.model_validate({
**COMMON,
"schema_version": 3,
"title": "Dominio Ablazione",
"kind": "domain",
"purposes": ["disambiguation", "schema_linking", "rewriting"],
"applies_to": {
"concepts": ["ablazione", "studio elettrofisiologico", "SEE"],
"tables": ["clinical.fact_ablazione"],
"columns": ["clinical.fact_ablazione.patient_id"],
},
"payload": {
"rule": (
"Il dominio Ablazione rappresenta la procedura transcatetere.\n\n"
"La fact centrale è `clinical.fact_ablazione`."
),
},
})
def test_v3_curated_markdown_replaces_frontmatter_tables_with_readable_sections(tmp_path):
evidence = _domain_evidence_v3()
path = tmp_path / "curated" / "domain" / "dominio-ablazione.md"
text = dump_curated_markdown(evidence)
parsed = parse_curated_markdown(text, path=path)
assert text.startswith("<!-- tht:metadata:")
assert not text.startswith("---\n")
assert not any(line.startswith("|") for line in text.splitlines())
assert "> **Dominio** · Italiano" in text
assert "**Scopi:** Disambiguazione · Collegamento allo schema · Riscrittura" in text
assert "## Ambito di applicazione" in text
assert "### Concetti\n\n- ablazione\n- studio elettrofisiologico\n- SEE" in text
assert "### Tabelle\n\n- `clinical.fact_ablazione`" in text
assert "### Colonne\n\n- `clinical.fact_ablazione.patient_id`" in text
assert "<summary>Dettagli tecnici e provenienza</summary>" in text
assert parsed == evidence
def test_v3_curated_markdown_renders_enum_values_as_a_list_instead_of_a_table(tmp_path):
evidence = CuratedEvidence.model_validate({
**COMMON,
"schema_version": 3,
"kind": "enum",
"payload": {
"column": "clinical.episode.discharge_status",
"values": {"D": "dimesso", "T": "trasferito | altra struttura"},
},
})
text = dump_curated_markdown(evidence)
assert "## Valori\n\n- `D`: dimesso\n- `T`: trasferito | altra struttura" in text
assert not any(line.startswith("|") for line in text.splitlines())
assert parse_curated_markdown(
text,
path=tmp_path / "curated" / "enum" / "discharge-status.md",
) == evidence
def test_v3_curated_markdown_rejects_visible_metadata_that_drifted_from_canonical_data():
text = dump_curated_markdown(_domain_evidence_v3()).replace(
"**Scopi:** Disambiguazione",
"**Scopi:** Testo alterato",
1,
)
with pytest.raises(ValueError, match="not canonical"):
parse_curated_markdown(text)
def test_v2_curated_markdown_renders_domain_content_in_the_markdown_body(tmp_path): def test_v2_curated_markdown_renders_domain_content_in_the_markdown_body(tmp_path):
evidence = _domain_evidence_v2() evidence = _domain_evidence_v2()
path = tmp_path / "curated" / "domain" / "dominio-ablazione.md" path = tmp_path / "curated" / "domain" / "dominio-ablazione.md"
+1 -1
View File
@@ -180,7 +180,7 @@ def migrate_cmd(
workspace_root: Path, workspace_root: Path,
json_output: Annotated[bool, typer.Option("--json", help="Write machine JSON to stdout.")] = False, json_output: Annotated[bool, typer.Option("--json", help="Write machine JSON to stdout.")] = False,
) -> None: ) -> None:
"""Rewrite legacy Curated units as readable Markdown without model calls.""" """Rewrite legacy Curated units as table-free v3 Markdown without model calls."""
root = _canonical_worktree(workspace_root) root = _canonical_worktree(workspace_root)
try: try:
report = migrate_workspace_evidence(root) report = migrate_workspace_evidence(root)
+6 -6
View File
@@ -694,7 +694,7 @@ def migrate_workspace_evidence(
*, *,
git_status: Callable[[Path], tuple[str, ...]] | None = None, git_status: Callable[[Path], tuple[str, ...]] | None = None,
) -> EvidenceMigrationReport: ) -> EvidenceMigrationReport:
"""Rewrite v1 Curated units as readable v2 Markdown without changing semantics.""" """Rewrite legacy Curated units as table-free v3 Markdown without changing semantics."""
workspace_root = workspace_root.resolve() workspace_root = workspace_root.resolve()
evidence_root = workspace_root / "evidence" evidence_root = workspace_root / "evidence"
_reject_dirty_authoring_state(workspace_root, git_status or _git_status) _reject_dirty_authoring_state(workspace_root, git_status or _git_status)
@@ -708,10 +708,10 @@ def migrate_workspace_evidence(
if len(documents_by_id) != len(documents): if len(documents_by_id) != len(documents):
raise EvidencePreparationError("duplicate_evidence_id") raise EvidencePreparationError("duplicate_evidence_id")
migrated = tuple(sorted( migrated = tuple(sorted(
document.id for document in documents if document.schema_version == 1 document.id for document in documents if document.schema_version in {1, 2}
)) ))
unchanged = tuple(sorted( unchanged = tuple(sorted(
document.id for document in documents if document.schema_version == 2 document.id for document in documents if document.schema_version == 3
)) ))
if not migrated: if not migrated:
return EvidenceMigrationReport( return EvidenceMigrationReport(
@@ -720,7 +720,7 @@ def migrate_workspace_evidence(
findings=validate_workspace_evidence(workspace_root).findings, findings=validate_workspace_evidence(workspace_root).findings,
) )
upgraded = { upgraded = {
evidence_id: document.model_copy(update={"schema_version": 2}) evidence_id: document.model_copy(update={"schema_version": 3})
for evidence_id, document in documents_by_id.items() for evidence_id, document in documents_by_id.items()
} }
findings = _stage_and_apply_authoring_tree(workspace_root, upgraded, manifest) findings = _stage_and_apply_authoring_tree(workspace_root, upgraded, manifest)
@@ -1007,7 +1007,7 @@ def _candidate_to_evidence(
mode="json", mode="json",
exclude={"schema_version", "existing_id", "supporting_excerpts"}, exclude={"schema_version", "existing_id", "supporting_excerpts"},
) )
data["schema_version"] = 2 data["schema_version"] = 3
data["id"] = evidence_id data["id"] = evidence_id
data["provenance"] = { data["provenance"] = {
"source_file": source_file, "source_file": source_file,
@@ -1028,7 +1028,7 @@ def _unsupported_unit(
message="The current source no longer supports this Evidence unit.", message="The current source no longer supports this Evidence unit.",
),) ),)
return evidence.model_copy(update={ return evidence.model_copy(update={
"schema_version": 2, "schema_version": 3,
"provenance": evidence.provenance.model_copy(update={ "provenance": evidence.provenance.model_copy(update={
"source_file": source_file, "source_file": source_file,
"source_sha256": source_hash, "source_sha256": source_hash,
+306 -20
View File
@@ -2,6 +2,9 @@
from __future__ import annotations from __future__ import annotations
import base64
import binascii
import json
import re import re
from pathlib import Path, PurePosixPath from pathlib import Path, PurePosixPath
from typing import Literal from typing import Literal
@@ -226,7 +229,7 @@ _EVIDENCE_ID = re.compile(r"^evidence:[a-z0-9]+(?:-[a-z0-9]+)*$")
class CuratedEvidence(StrictModel): class CuratedEvidence(StrictModel):
schema_version: Literal[1, 2] schema_version: Literal[1, 2, 3]
id: str id: str
title: str title: str
kind: EvidenceKind kind: EvidenceKind
@@ -272,6 +275,10 @@ _V2_LABELS = {
"values": "Values", "values": "Values",
"meaning": "Meaning", "meaning": "Meaning",
"variants": "Variants", "variants": "Variants",
"applies_to": "Applies to",
"concepts": "Concepts",
"technical_details": "Technical details and provenance",
"purposes": "Purposes",
}, },
"it": { "it": {
"column": "Colonna", "column": "Colonna",
@@ -297,6 +304,10 @@ _V2_LABELS = {
"values": "Valori", "values": "Valori",
"meaning": "Significato", "meaning": "Significato",
"variants": "Varianti", "variants": "Varianti",
"applies_to": "Ambito di applicazione",
"concepts": "Concetti",
"technical_details": "Dettagli tecnici e provenienza",
"purposes": "Scopi",
}, },
} }
_V2_FIELD = re.compile( _V2_FIELD = re.compile(
@@ -307,6 +318,43 @@ _V2_EXCERPT_SEPARATOR = "<!-- tht:excerpt-separator -->"
_V2_EMPTY_LIST = "<!-- tht:empty-list -->" _V2_EMPTY_LIST = "<!-- tht:empty-list -->"
_V2_REVIEW_SEPARATOR = "<!-- tht:review-separator -->" _V2_REVIEW_SEPARATOR = "<!-- tht:review-separator -->"
_V2_REVIEW_FIELD = "<!-- tht:review-field -->" _V2_REVIEW_FIELD = "<!-- tht:review-field -->"
_V3_METADATA = re.compile(r"\A<!-- tht:metadata:([A-Za-z0-9+/=]+) -->\n")
_V3_KIND_LABELS = {
"en": {
"glossary": "Glossary",
"domain": "Domain",
"enum": "Enumeration",
"example": "Example",
"mapping": "Mapping",
"normalization": "Normalization",
"formula": "Formula",
"reference": "Reference",
},
"it": {
"glossary": "Glossario",
"domain": "Dominio",
"enum": "Enumerazione",
"example": "Esempio",
"mapping": "Mappatura",
"normalization": "Normalizzazione",
"formula": "Formula",
"reference": "Riferimento",
},
}
_V3_PURPOSE_LABELS = {
"en": {
"disambiguation": "Disambiguation",
"rewriting": "Rewriting",
"schema_linking": "Schema linking",
"sql_generation": "SQL generation",
},
"it": {
"disambiguation": "Disambiguazione",
"rewriting": "Riscrittura",
"schema_linking": "Collegamento allo schema",
"sql_generation": "Generazione SQL",
},
}
def _v2_labels(language: str) -> dict[str, str]: def _v2_labels(language: str) -> dict[str, str]:
@@ -425,6 +473,40 @@ def _parse_v2_values(value: str, labels: dict[str, str]) -> dict[str, str]:
return parsed return parsed
def _render_v3_values(values: dict[str, str], labels: dict[str, str]) -> str:
if not values:
return f"{_V2_EMPTY_LIST}\n_{labels['empty']}._"
if any("`" in value for value in values):
raise ValueError("curated evidence enum values must not contain backticks")
rendered: list[str] = []
for value, meaning in sorted(values.items()):
lines = meaning.split("\n")
rendered.append(f"- `{value}`: {lines[0]}")
rendered.extend(f" {line}" for line in lines[1:])
return "\n".join(rendered)
def _parse_v3_values(value: str, labels: dict[str, str]) -> dict[str, str]:
if value == f"{_V2_EMPTY_LIST}\n_{labels['empty']}._":
return {}
parsed: dict[str, list[str]] = {}
current: str | None = None
for line in value.split("\n"):
match = re.fullmatch(r"- `([^`]+)`: ?(.*)", line)
if match is not None:
current = match.group(1)
if current in parsed:
raise ValueError("curated evidence enum value appears more than once")
parsed[current] = [match.group(2)]
continue
if current is None or not line.startswith(" "):
raise ValueError("curated evidence values list is malformed")
parsed[current].append(line[2:])
if not parsed:
raise ValueError("curated evidence values list is malformed")
return {key: "\n".join(lines) for key, lines in parsed.items()}
def _render_v2_payload(value: CuratedEvidence, labels: dict[str, str]) -> list[str]: def _render_v2_payload(value: CuratedEvidence, labels: dict[str, str]) -> list[str]:
payload = value.payload payload = value.payload
if value.kind == "glossary": if value.kind == "glossary":
@@ -485,6 +567,18 @@ def _render_v2_payload(value: CuratedEvidence, labels: dict[str, str]) -> list[s
raise ValueError(f"unsupported curated evidence kind {value.kind}") raise ValueError(f"unsupported curated evidence kind {value.kind}")
def _render_v3_payload(value: CuratedEvidence, labels: dict[str, str]) -> list[str]:
if value.kind != "enum":
return _render_v2_payload(value, labels)
payload = value.payload
return [
_render_v2_field("column", labels["column"], f"`{payload.column}`"),
_render_v2_field("values", labels["values"], _render_v3_values(
payload.values, labels,
)),
]
def _render_v2_review_items(value: CuratedEvidence, labels: dict[str, str]) -> str: def _render_v2_review_items(value: CuratedEvidence, labels: dict[str, str]) -> str:
rendered: list[str] = [] rendered: list[str] = []
for item in value.review_items: for item in value.review_items:
@@ -523,6 +617,121 @@ def _render_v2_body(value: CuratedEvidence) -> str:
return f"# {value.title}\n\n" + "\n\n".join(fields) + "\n" return f"# {value.title}\n\n" + "\n\n".join(fields) + "\n"
def _render_v3_block(name: str, content: str) -> str:
if "<!-- tht:field:" in content or "<!-- /tht:field:" in content:
raise ValueError(f"curated evidence {name} contains a reserved marker")
return (
f"<!-- tht:field:{name} -->\n"
f"{content}\n"
f"<!-- /tht:field:{name} -->"
)
def _v3_locale(value: CuratedEvidence) -> str:
return "it" if value.language.lower().startswith("it") else "en"
def _render_v3_overview(value: CuratedEvidence, labels: dict[str, str]) -> str:
locale = _v3_locale(value)
language = "Italiano" if locale == "it" else "English"
kind = _V3_KIND_LABELS[locale][value.kind]
purposes = " · ".join(_V3_PURPOSE_LABELS[locale][purpose] for purpose in value.purposes)
if not purposes:
purposes = labels["empty"]
return _render_v3_block(
"overview",
f"> **{kind}** · {language}\n>\n> **{labels['purposes']}:** {purposes}",
)
def _render_v3_scope(value: CuratedEvidence, labels: dict[str, str]) -> str:
sections: list[str] = []
for label, values, code in (
(labels["concepts"], value.applies_to.concepts, False),
(labels["tables"], value.applies_to.tables, True),
(labels["columns"], value.applies_to.columns, True),
):
if values:
sections.append(
f"### {label}\n\n"
f"{_render_v2_list(values, code=code, empty_label=labels['empty'])}"
)
content = "\n\n".join(sections) if sections else f"_{labels['empty']}._"
return _render_v3_block(
"applies_to",
f"## {labels['applies_to']}\n\n{content}",
)
def _render_v3_provenance(value: CuratedEvidence, labels: dict[str, str]) -> str:
locale = _v3_locale(value)
technical_labels = {
"en": {
"schema": "Schema version",
"kind": "Kind",
"language": "Language",
"source": "Source file",
},
"it": {
"schema": "Versione schema",
"kind": "Tipo",
"language": "Lingua",
"source": "File sorgente",
},
}[locale]
content = (
"<details>\n"
f"<summary>{labels['technical_details']}</summary>\n\n"
f"- **ID:** `{value.id}`\n"
f"- **{technical_labels['schema']}:** `{value.schema_version}`\n"
f"- **{technical_labels['kind']}:** `{value.kind}`\n"
f"- **{technical_labels['language']}:** `{value.language}`\n"
f"- **{technical_labels['source']}:** `{value.provenance.source_file}`\n"
f"- **SHA-256:** `{value.provenance.source_sha256}`\n\n"
"</details>"
)
return _render_v3_block("provenance", content)
def _render_v3_metadata(value: CuratedEvidence) -> str:
data = value.model_dump(mode="json", exclude={"payload", "review_items"})
data["provenance"].pop("supporting_excerpts")
encoded = base64.b64encode(json.dumps(
data,
ensure_ascii=False,
separators=(",", ":"),
sort_keys=True,
).encode("utf-8")).decode("ascii")
return f"<!-- tht:metadata:{encoded} -->"
def _render_v3_body(value: CuratedEvidence) -> str:
if "\n" in value.title:
raise ValueError("curated evidence title must be single-line in v3")
labels = _v2_labels(value.language)
fields = [
_render_v3_overview(value, labels),
_render_v3_scope(value, labels),
*_render_v3_payload(value, labels),
_render_v2_field(
"supporting_excerpts",
labels["supporting_excerpts"],
f"\n{_V2_EXCERPT_SEPARATOR}\n".join(
_render_v2_excerpt(excerpt)
for excerpt in value.provenance.supporting_excerpts
),
),
]
if value.review_items:
fields.append(_render_v2_field(
"review_items",
labels["review_items"],
_render_v2_review_items(value, labels),
))
fields.append(_render_v3_provenance(value, labels))
return f"# {value.title}\n\n" + "\n\n".join(fields) + "\n"
def _parse_v2_field_content(name: str, block: str) -> str: def _parse_v2_field_content(name: str, block: str) -> str:
try: try:
heading, content = block.split("\n\n", 1) heading, content = block.split("\n\n", 1)
@@ -615,6 +824,17 @@ def _parse_v2_payload(
raise ValueError("curated evidence body kind is unsupported") raise ValueError("curated evidence body kind is unsupported")
def _parse_v3_payload(
kind: str, fields: dict[str, str], labels: dict[str, str],
) -> tuple[dict, set[str]]:
if kind != "enum":
return _parse_v2_payload(kind, fields, labels)
return {
"column": _parse_inline_code(fields.get("column", ""), "column"),
"values": _parse_v3_values(fields.get("values", ""), labels),
}, {"column", "values"}
def _parse_v2_review_items(value: str) -> tuple[ReviewItem, ...]: def _parse_v2_review_items(value: str) -> tuple[ReviewItem, ...]:
items: list[ReviewItem] = [] items: list[ReviewItem] = []
for raw_item in value.split(f"\n{_V2_REVIEW_SEPARATOR}\n"): for raw_item in value.split(f"\n{_V2_REVIEW_SEPARATOR}\n"):
@@ -680,35 +900,101 @@ def _parse_v2_body(data: dict, body: str) -> dict:
return data return data
def parse_curated_markdown(text: str, *, path: Path | None = None) -> CuratedEvidence: def _parse_v3_body(data: dict, body: str) -> dict:
"""Parse the canonical frontmatter representation of one Curated Evidence unit.""" kind = data.get("kind")
if not text.startswith("---\n"): body_owned = {"payload", "review_items"}
raise ValueError("curated evidence requires YAML frontmatter") if isinstance(kind, str):
try: body_owned.add(kind)
_, frontmatter, body = text.split("---\n", 2) if body_owned.intersection(data):
except ValueError as error: raise ValueError("curated evidence v3 metadata contains body-owned fields")
raise ValueError("curated evidence frontmatter is malformed") from error title = data.get("title")
raw = yaml.safe_load(frontmatter) if not isinstance(title, str) or not body.startswith(f"# {title}\n"):
raise ValueError("curated evidence body title must match its metadata")
fields: dict[str, str] = {}
for match in _V2_FIELD.finditer(body):
name = match.group(1)
if name in fields:
raise ValueError(f"curated evidence field {name} appears more than once")
raw_content = match.group(2)
fields[name] = (
raw_content
if name in {"overview", "applies_to", "provenance"}
else _parse_v2_field_content(name, raw_content)
)
skeleton = _V2_FIELD.sub("", body).strip()
if skeleton != f"# {title}":
raise ValueError("curated evidence body contains unstructured content")
labels = _v2_labels(str(data.get("language", "")))
payload, payload_fields = _parse_v3_payload(kind, fields, labels)
common_fields = {"overview", "applies_to", "supporting_excerpts", "provenance"}
if "review_items" in fields:
common_fields.add("review_items")
if set(fields) != payload_fields | common_fields:
raise ValueError("curated evidence body fields do not match its kind")
provenance = data.get("provenance")
if not isinstance(provenance, dict) or "supporting_excerpts" in provenance:
raise ValueError("curated evidence v3 provenance is malformed")
provenance["supporting_excerpts"] = _parse_v2_excerpts(fields["supporting_excerpts"])
data["review_items"] = (
_parse_v2_review_items(fields["review_items"])
if "review_items" in fields
else []
)
data["payload"] = payload
return data
def _parse_v3_document(text: str) -> dict:
match = _V3_METADATA.match(text)
if match is None:
raise ValueError("curated evidence v3 metadata is malformed")
try: try:
decoded = base64.b64decode(match.group(1), validate=True).decode("utf-8")
raw = json.loads(decoded)
data = dict(raw) data = dict(raw)
except (TypeError, ValueError) as error: except (binascii.Error, UnicodeDecodeError, json.JSONDecodeError, TypeError, ValueError) as error:
raise ValueError("curated evidence frontmatter must be a mapping") from error raise ValueError("curated evidence v3 metadata is malformed") from error
if data.get("schema_version") == 2: if data.get("schema_version") != 3:
data = _parse_v2_body(data, body) raise ValueError("curated evidence v3 metadata has the wrong schema version")
return _parse_v3_body(data, text[match.end():])
def parse_curated_markdown(text: str, *, path: Path | None = None) -> CuratedEvidence:
"""Parse one canonical Curated Evidence Markdown document."""
if text.startswith("<!-- tht:metadata:"):
data = _parse_v3_document(text)
else: else:
if body.strip(): if not text.startswith("---\n"):
raise ValueError("curated evidence must not contain an ignored body") raise ValueError("curated evidence requires canonical metadata")
kind = data.get("kind") try:
if "payload" not in data and kind in _PAYLOAD_TYPE_BY_KIND: _, frontmatter, body = text.split("---\n", 2)
data["payload"] = data.pop(kind, None) except ValueError as error:
raise ValueError("curated evidence frontmatter is malformed") from error
raw = yaml.safe_load(frontmatter)
try:
data = dict(raw)
except (TypeError, ValueError) as error:
raise ValueError("curated evidence frontmatter must be a mapping") from error
if data.get("schema_version") == 2:
data = _parse_v2_body(data, body)
else:
if body.strip():
raise ValueError("curated evidence must not contain an ignored body")
kind = data.get("kind")
if "payload" not in data and kind in _PAYLOAD_TYPE_BY_KIND:
data["payload"] = data.pop(kind, None)
evidence = CuratedEvidence.model_validate(data) evidence = CuratedEvidence.model_validate(data)
if evidence.schema_version == 3 and dump_curated_markdown(evidence) != text:
raise ValueError("curated evidence v3 presentation is not canonical")
if path is not None: if path is not None:
_validate_kind_directory(path, evidence.kind) _validate_kind_directory(path, evidence.kind)
return evidence return evidence
def dump_curated_markdown(value: CuratedEvidence) -> str: def dump_curated_markdown(value: CuratedEvidence) -> str:
"""Render canonical frontmatter with a human-readable kind-specific payload key.""" """Render one canonical Curated Evidence Markdown document."""
if value.schema_version == 3:
return f"{_render_v3_metadata(value)}\n{_render_v3_body(value)}"
if value.schema_version == 2: if value.schema_version == 2:
data = value.model_dump(mode="json", exclude={"payload", "review_items"}) data = value.model_dump(mode="json", exclude={"payload", "review_items"})
data["provenance"].pop("supporting_excerpts") data["provenance"].pop("supporting_excerpts")