← Files ClaraARCHIVED FILE
modules/period-comparison/assets/period-comparison-review-widget.html
21.9 KB · Oct 6, 2026 · 06:02 UTC
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Period Comparison Review</title>
<style>
:root {
color-scheme: light;
--paper: #ffffff;
--surface: #ffffff;
--surface-muted: #f7f9f6;
--surface-strong: #eef3ed;
--soft: #f7f9f6;
--soft-strong: #eef3ed;
--ink: #243027;
--text: #465148;
--muted: #6d776f;
--border: #dfe6dd;
--border-strong: #cfd9d0;
--accent: #496a60;
--accent-strong: #263b34;
--accent-soft: #e7eee9;
--danger: #8f4438;
--danger-bg: #fbf4f2;
--warning: #7d642d;
--warning-bg: #fbf7ee;
--ok: #3f6b55;
--ok-bg: #f0f7f2;
--info: #425f72;
--info-bg: #f1f6f8;
--radius: 8px;
--content: 1240px;
--shadow: none;
}
* { box-sizing: border-box; }
html, body { background: var(--paper); }
body {
margin: 0;
color: var(--text);
font-family: "Instrument Sans", "Aptos", "Segoe UI", -apple-system, BlinkMacSystemFont, sans-serif;
font-size: 1rem;
line-height: 1.5;
font-kerning: normal;
letter-spacing: 0;
text-rendering: optimizeLegibility;
}
button, input, textarea, select {
font: inherit;
letter-spacing: 0;
}
button { -webkit-tap-highlight-color: transparent; }
.app {
min-height: 100vh;
padding: 1.25rem;
background: var(--paper);
}
.shell {
max-width: var(--content);
margin: 0 auto;
}
.topbar {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
padding-bottom: 1rem;
margin-bottom: 1rem;
border-bottom: 1px solid var(--border);
}
h1 {
margin: 0 0 0.35rem;
color: var(--ink);
font-size: 1.35rem;
font-weight: 650;
line-height: 1.18;
letter-spacing: 0;
}
.subtitle {
max-width: 68ch;
margin: 0;
color: var(--muted);
font-size: 0.95rem;
line-height: 1.45;
}
.pill {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 2rem;
max-width: 18rem;
border: 1px solid var(--border);
border-radius: 999px;
padding: 0.25rem 0.75rem;
background: var(--surface);
color: var(--accent-strong);
font-size: 0.78rem;
font-weight: 650;
line-height: 1.2;
text-align: center;
overflow-wrap: anywhere;
}
.summary {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(9.5rem, 1fr));
gap: 0.65rem;
margin-bottom: 1rem;
}
.metric {
min-width: 0;
min-height: 5.25rem;
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 0.85rem;
background: var(--surface);
}
.metric span {
display: block;
color: var(--muted);
font-size: 0.78rem;
font-weight: 520;
line-height: 1.25;
overflow-wrap: anywhere;
}
.metric strong {
display: block;
margin-top: 0.3rem;
color: var(--ink);
font-size: 1.28rem;
font-weight: 650;
line-height: 1.18;
letter-spacing: 0;
overflow-wrap: anywhere;
font-variant-numeric: tabular-nums;
}
.toolbar {
display: grid;
grid-template-columns: minmax(14rem, 1fr) auto;
gap: 0.65rem;
align-items: start;
margin-bottom: 0.85rem;
}
.search {
width: 100%;
min-height: 2.75rem;
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 0.65rem 0.8rem;
background: var(--surface);
color: var(--ink);
outline: none;
}
.search::placeholder { color: var(--muted); }
.search:focus-visible,
.tab:focus-visible,
.row:focus-visible,
button:focus-visible {
outline: 2px solid var(--accent-soft);
outline-offset: 2px;
}
.tabs {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
justify-content: flex-end;
}
.tab {
min-height: 2.25rem;
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 0.48rem 0.7rem;
background: var(--surface);
color: var(--text);
cursor: pointer;
font-size: 0.9rem;
line-height: 1.2;
overflow-wrap: anywhere;
}
.tab:hover { background: var(--soft); }
.tab[aria-pressed="true"] {
border-color: var(--border-strong);
background: var(--accent-soft);
color: var(--accent-strong);
font-weight: 650;
}
.content {
display: grid;
grid-template-columns: minmax(0, 1.08fr) minmax(20rem, 0.92fr);
gap: 0.9rem;
align-items: start;
}
.panel {
min-width: 0;
overflow: hidden;
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--surface);
}
.panel-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
min-height: 3rem;
border-bottom: 1px solid var(--border);
padding: 0.75rem 0.9rem;
background: var(--soft);
color: var(--muted);
font-size: 0.84rem;
}
.panel-header h2 {
margin: 0;
color: var(--ink);
font-size: 0.95rem;
font-weight: 650;
line-height: 1.2;
letter-spacing: 0;
}
.rows {
max-height: 700px;
overflow: auto;
}
.row {
display: grid;
grid-template-columns: minmax(7.5rem, 0.32fr) minmax(0, 1fr) minmax(6.75rem, 10rem);
gap: 0.75rem;
width: 100%;
min-height: 3.75rem;
border: 0;
border-left: 2px solid transparent;
border-bottom: 1px solid var(--border);
padding: 0.72rem 0.85rem 0.72rem calc(0.85rem - 2px);
background: var(--surface);
color: var(--text);
text-align: left;
cursor: pointer;
}
.row:last-child { border-bottom: 0; }
.row:hover,
.row.is-selected {
background: var(--soft);
}
.row.is-selected {
border-left-color: var(--accent);
}
.type {
color: var(--muted);
font-size: 0.78rem;
line-height: 1.25;
overflow-wrap: anywhere;
}
.title {
color: var(--ink);
font-weight: 650;
line-height: 1.3;
overflow-wrap: anywhere;
}
.path {
display: block;
margin-top: 0.22rem;
color: var(--muted);
font-size: 0.78rem;
line-height: 1.3;
overflow-wrap: anywhere;
}
.action,
.tag {
justify-self: end;
align-self: start;
max-width: 10rem;
border: 1px solid var(--border);
border-radius: 999px;
padding: 0.25rem 0.55rem;
background: var(--warning-bg);
color: var(--warning);
font-size: 0.76rem;
font-weight: 620;
line-height: 1.2;
text-align: center;
overflow-wrap: anywhere;
}
.row[data-kind*="artifact"] .action,
.row[data-kind*="source"] .action,
.row[data-kind*="source"] .action,
.row[data-kind*="inventory"] .action,
.row[data-kind*="supported"] .action,
.row[data-kind*="section"] .action,
.row[data-kind*="table"] .action,
.row[data-kind*="chart"] .action,
.row[data-kind*="context"] .action,
.row[data-kind*="field"] .action,
.row[data-kind*="memo"] .action,
.row[data-kind*="email"] .action,
.row[data-kind*="prompt"] .action,
.row[data-kind*="domain"] .action,
.row[data-kind*="driver"] .action,
.row[data-kind*="movement"] .action,
.row[data-kind*="window"] .action,
.row[data-kind*="match"] .action {
background: var(--ok-bg);
color: var(--ok);
}
.row[data-kind*="error"] .action,
.row[data-kind*="missing"] .action,
.row[data-kind*="mismatch"] .action,
.row[data-kind*="unmatched"] .action,
.row[data-kind*="issue"] .action,
.row[data-kind*="failed"] .action,
.row[data-kind*="anomaly"] .action,
.row[data-kind*="warning"] .action,
.row[data-kind*="duplicate"] .action,
.row[data-kind*="followup"] .action {
background: var(--danger-bg);
color: var(--danger);
}
.details {
padding: 0.95rem;
}
.details h3 {
margin: 0 0 0.55rem;
color: var(--ink);
font-size: 1.05rem;
font-weight: 650;
line-height: 1.25;
letter-spacing: 0;
overflow-wrap: anywhere;
}
.details dl {
display: grid;
grid-template-columns: minmax(7.5rem, 0.32fr) minmax(0, 1fr);
gap: 0.45rem 0.8rem;
margin: 0.85rem 0;
}
.details dt {
color: var(--muted);
font-size: 0.8rem;
line-height: 1.3;
}
.details dd {
margin: 0;
color: var(--text);
overflow-wrap: anywhere;
}
h4 {
margin: 1rem 0 0.45rem;
color: var(--ink);
font-size: 0.86rem;
font-weight: 650;
line-height: 1.25;
letter-spacing: 0;
}
pre {
max-height: 24rem;
overflow: auto;
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 0.8rem;
background: var(--soft);
color: var(--text);
white-space: pre-wrap;
overflow-wrap: anywhere;
font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace;
font-size: 0.78rem;
line-height: 1.45;
}
.empty {
padding: 1.35rem;
color: var(--muted);
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
scroll-behavior: auto !important;
transition-duration: 0.001ms !important;
animation-duration: 0.001ms !important;
animation-iteration-count: 1 !important;
}
}
@media (max-width: 980px) {
.content { grid-template-columns: 1fr; }
.toolbar { grid-template-columns: 1fr; }
.tabs { justify-content: flex-start; }
}
@media (max-width: 760px) {
.app { padding: 1rem; }
.topbar { flex-direction: column; align-items: stretch; }
.pill { align-self: flex-start; }
.summary { grid-template-columns: repeat(auto-fit, minmax(8.5rem, 1fr)); }
.row { grid-template-columns: 1fr; }
.action, .tag { justify-self: start; max-width: 100%; }
.details dl { grid-template-columns: 1fr; }
}
</style>
</head>
<body>
<main class="app">
<div class="shell">
<section class="topbar">
<div>
<h1 id="page-title">Period Comparison Review</h1>
<p class="subtitle" id="subtitle">Loading payload...</p>
</div>
<span class="pill" id="status-pill">MCP widget</span>
</section>
<section class="summary" id="summary"></section>
<section class="toolbar">
<input id="search" class="search" type="search" placeholder="Search movement, window, chart, context, follow-up, artifact">
<div class="tabs" id="tabs"></div>
</section>
<section class="content">
<div class="panel">
<div class="panel-header">
<h2 id="items-title">Review items</h2>
<span id="count"></span>
</div>
<div class="rows" id="rows"></div>
</div>
<div class="panel">
<div class="panel-header">
<h2 id="detail-title">Detail</h2>
</div>
<div class="details" id="details"></div>
</div>
</section>
</div>
</main>
<script>
const UI_TEXT = {
en: {
documentTitle: "Period Comparison Review",
title: "Period Comparison Review",
loading: "Loading payload...",
widget: "MCP widget",
search: "Search movement, window, chart, context, follow-up, artifact",
reviewItems: "Review items",
detail: "Detail",
run: "Run",
unavailable: "n/a",
all: "All",
review: "review",
noMatch: "No item matches the current filters.",
selectItem: "Select a review item.",
data: "Data",
sourceRecords: "Source Records",
types: {
period_movement: "Movement",
period_window: "Window",
chart_artifact: "Chart",
context_artifact: "Context",
followup_request: "Follow-up",
report_artifact: "Report",
review_artifact: "Artifact",
},
fields: { type: "Type", action: "Action", source: "Source", output: "Output", status: "Status" },
metrics: { current: "Current", previous: "Previous", delta: "Delta", deltaPercent: "Delta %", charts: "Charts", followups: "Follow-ups" },
actions: { accept: "Accept", reject: "Reject", edit: "Edit", mark_unclear: "Mark unclear", request_more_documents: "Request documents", skip: "Skip" },
statuses: { empty: "Empty", ready_for_review: "Ready for review", needs_review: "Needs review" },
},
es: {
documentTitle: "Revisión de comparación de periodos",
title: "Revisión de comparación de periodos",
loading: "Cargando datos...",
widget: "Widget MCP",
search: "Buscar movimiento, ventana, gráfico, contexto, seguimiento o artefacto",
reviewItems: "Elementos para revisar",
detail: "Detalle",
run: "Ejecución",
unavailable: "no disponible",
all: "Todos",
review: "revisar",
noMatch: "Ningún elemento coincide con los filtros actuales.",
selectItem: "Seleccione un elemento para revisar.",
data: "Datos",
sourceRecords: "Registros de origen",
types: {
period_movement: "Movimiento",
period_window: "Ventana",
chart_artifact: "Gráfico",
context_artifact: "Contexto",
followup_request: "Seguimiento",
report_artifact: "Informe",
review_artifact: "Artefacto",
},
fields: { type: "Tipo", action: "Acción", source: "Fuente", output: "Salida", status: "Estado" },
metrics: { current: "Actual", previous: "Anterior", delta: "Variación", deltaPercent: "Variación %", charts: "Gráficos", followups: "Seguimientos" },
actions: { accept: "Aceptar", reject: "Rechazar", edit: "Editar", mark_unclear: "Marcar como incierto", request_more_documents: "Solicitar documentos", skip: "Omitir" },
statuses: { empty: "Vacío", ready_for_review: "Listo para revisión", needs_review: "Pendiente de revisión" },
},
};
const FALLBACK = {
widget_type: "period_comparison_review",
review_payload: {
language: "en",
run_id: "preview",
status: "empty",
item_count: 0,
items: [],
summary: {},
},
};
const state = {
payload: FALLBACK,
selectedType: "all",
selectedId: null,
query: "",
};
function toolOutput() {
return window.openai?.toolOutput || window.openai?.structuredContent || FALLBACK;
}
function esc(value) {
return String(value ?? "").replace(/[&<>"']/g, (char) => ({
"&": "&",
"<": "<",
">": ">",
'"': """,
"'": "'",
})[char]);
}
function reviewPayload() {
return state.payload.review_payload || FALLBACK.review_payload;
}
function normalizeLanguage(value) {
const normalized = String(value || "en").trim().toLowerCase().replaceAll("_", "-");
return ["es", "spa", "spanish", "espanol", "español"].includes(normalized.split("-", 1)[0]) ? "es" : "en";
}
function language() {
return normalizeLanguage(reviewPayload().language || state.payload.run_intake?.language);
}
function ui() {
return UI_TEXT[language()] || UI_TEXT.en;
}
function typeLabel(value) {
return ui().types[value] || value;
}
function actionLabel(value) {
if (value === "review") return ui().review;
return ui().actions[value] || value;
}
function statusLabel(value) {
return ui().statuses[value] || value;
}
function applyLanguage() {
const copy = ui();
document.documentElement.lang = language();
document.title = copy.documentTitle;
document.getElementById("page-title").textContent = copy.title;
document.getElementById("subtitle").textContent = copy.loading;
document.getElementById("status-pill").textContent = copy.widget;
document.getElementById("search").placeholder = copy.search;
document.getElementById("items-title").textContent = copy.reviewItems;
document.getElementById("detail-title").textContent = copy.detail;
}
function items() {
return Array.isArray(reviewPayload().items) ? reviewPayload().items : [];
}
function filteredItems() {
const q = state.query.trim().toLowerCase();
return items().filter((item) => {
const typeOk = state.selectedType === "all" || item.item_type === state.selectedType;
if (!typeOk) return false;
if (!q) return true;
return JSON.stringify(item).toLowerCase().includes(q);
});
}
function formatNumber(value) {
if (value == null || value === "") return ui().unavailable;
const number = Number(value);
if (!Number.isFinite(number)) return String(value);
return new Intl.NumberFormat(language() === "es" ? "es-ES" : undefined, { maximumFractionDigits: 1 }).format(number);
}
function formatDeltaPercent(value) {
if (value == null || value === "") return ui().unavailable;
const number = Number(value);
if (!Number.isFinite(number)) return String(value);
return `${new Intl.NumberFormat(language() === "es" ? "es-ES" : undefined, { maximumFractionDigits: 1 }).format(number)}%`;
}
function metric(label, value) {
return `<article class="metric"><span>${esc(label)}</span><strong>${esc(value)}</strong></article>`;
}
function renderSummary() {
const payload = reviewPayload();
const summary = payload.summary || {};
const copy = ui();
document.getElementById("subtitle").textContent = `${copy.run} ${payload.run_id || copy.unavailable}`;
document.getElementById("status-pill").textContent = statusLabel(payload.status || "ready_for_review");
document.getElementById("summary").innerHTML = [
metric(copy.metrics.current, formatNumber(summary.current_total)),
metric(copy.metrics.previous, formatNumber(summary.previous_total)),
metric(copy.metrics.delta, formatNumber(summary.delta)),
metric(copy.metrics.deltaPercent, formatDeltaPercent(summary.delta_percent)),
metric(copy.metrics.charts, summary.chart_count ?? 0),
metric(copy.metrics.followups, summary.followup_request_count ?? 0),
].join("");
}
function renderTabs() {
const counts = new Map([["all", items().length]]);
for (const item of items()) counts.set(item.item_type, (counts.get(item.item_type) || 0) + 1);
document.getElementById("tabs").innerHTML = Array.from(counts.entries()).map(([type, count]) => {
const label = type === "all" ? ui().all : typeLabel(type);
return `<button class="tab" data-type="${esc(type)}" aria-pressed="${type === state.selectedType}">${esc(label)} (${count})</button>`;
}).join("");
}
function rowHtml(item) {
const selected = item.id === state.selectedId ? " is-selected" : "";
return `<button class="row${selected}" data-id="${esc(item.id)}" data-kind="${esc(item.item_type)}">
<span class="type">${esc(typeLabel(item.item_type))}</span>
<span>
<span class="title">${esc(item.title)}</span>
<span class="path">${esc(item.source_path || item.output_path || "")}</span>
</span>
<span class="action">${esc(actionLabel(item.recommended_action || "review"))}</span>
</button>`;
}
function renderRows() {
const rows = filteredItems();
document.getElementById("count").textContent = `${rows.length} / ${items().length}`;
document.getElementById("rows").innerHTML = rows.length
? rows.map(rowHtml).join("")
: `<div class="empty">${esc(ui().noMatch)}</div>`;
if (!state.selectedId && rows[0]) state.selectedId = rows[0].id;
if (state.selectedId && !items().some((item) => item.id === state.selectedId)) {
state.selectedId = rows[0]?.id || null;
}
renderDetails();
}
function detailsHtml(item) {
if (!item) return `<div class="empty">${esc(ui().selectItem)}</div>`;
const copy = ui();
const fields = [
[copy.fields.type, typeLabel(item.item_type)],
[copy.fields.action, actionLabel(item.recommended_action || "")],
[copy.fields.source, item.source_path || ""],
[copy.fields.output, item.output_path || ""],
[copy.fields.status, statusLabel(item.status || "")],
].filter(([, value]) => value);
return `
<h3>${esc(item.title)}</h3>
<dl>${fields.map(([key, value]) => `<dt>${esc(key)}</dt><dd>${esc(value)}</dd>`).join("")}</dl>
<h4>${esc(copy.data)}</h4>
<pre>${esc(JSON.stringify(item.data || {}, null, 2))}</pre>
<h4>${esc(copy.sourceRecords)}</h4>
<pre>${esc(JSON.stringify(item.source_records || [], null, 2))}</pre>
`;
}
function renderDetails() {
const item = items().find((candidate) => candidate.id === state.selectedId);
document.getElementById("details").innerHTML = detailsHtml(item);
}
function render() {
applyLanguage();
renderSummary();
renderTabs();
renderRows();
}
document.getElementById("search").addEventListener("input", (event) => {
state.query = event.target.value;
renderRows();
});
document.getElementById("tabs").addEventListener("click", (event) => {
const button = event.target.closest("button[data-type]");
if (!button) return;
state.selectedType = button.dataset.type;
state.selectedId = null;
render();
});
document.getElementById("rows").addEventListener("click", (event) => {
const button = event.target.closest("button[data-id]");
if (!button) return;
state.selectedId = button.dataset.id;
renderRows();
});
state.payload = toolOutput();
render();
</script>
</body>
</html>
SHA-256: 5ccda6676515ec45bda869ac63850322c05d7bacc39bcd86dfc6140828f483b2