diff --git a/index.html b/index.html
index 8d3a91e..5940a5f 100644
--- a/index.html
+++ b/index.html
@@ -17,14 +17,14 @@
Refract
- Timeline
- Changes
diff --git a/src/citations/SourceChangeTable.ts b/src/citations/SourceChangeTable.ts
index ee530dd..c34acad 100644
--- a/src/citations/SourceChangeTable.ts
+++ b/src/citations/SourceChangeTable.ts
@@ -41,12 +41,13 @@ export class SourceChangeTable {
}
const table = document.createElement("table");
- table.className = "data-table";
+ table.className = "data-table source-table";
+ // Detail is last so it takes the width the short columns leave. The
+ // revision id is on each row's tooltip rather than in a column of its own.
const thead = document.createElement("thead");
thead.innerHTML = `
- | Rev |
Date |
Type |
Section |
@@ -58,14 +59,19 @@ export class SourceChangeTable {
const tbody = document.createElement("tbody");
for (const ch of changes) {
const tr = document.createElement("tr");
+ tr.title = `Revision r${ch.revisionId}`;
const ts = new Date(ch.timestamp);
- tr.innerHTML = `
- r${ch.revisionId} |
- ${ts.toLocaleDateString()} |
- ${ch.changeType} |
- ${ch.section} |
- ${ch.detail} |
- `;
+ // textContent, not innerHTML: section and detail come from the loaded file.
+ for (const text of [
+ ts.toLocaleDateString(),
+ ch.changeType,
+ ch.section,
+ ch.detail,
+ ]) {
+ const td = document.createElement("td");
+ td.textContent = text;
+ tr.appendChild(td);
+ }
tbody.appendChild(tr);
}
table.appendChild(tbody);
diff --git a/src/export/SchemaViewer.ts b/src/export/SchemaViewer.ts
index 4be8847..942540f 100644
--- a/src/export/SchemaViewer.ts
+++ b/src/export/SchemaViewer.ts
@@ -30,39 +30,47 @@ export class SchemaViewer {
}
const keys = this.collectKeys();
+ const infos = new Map(keys.map((key) => [key, this.getTypeInfo(key)]));
+ const allPresent = [...infos.values()].every((i) => i.inEveryEvent);
const schema = document.createElement("div");
schema.className = "schema-viewer";
+ // Presence is stated once for the fields every event carries; only the
+ // fields some events lack show their share.
const headerEl = document.createElement("div");
headerEl.style.cssText =
"color:var(--text-dim);margin-bottom:0.5rem;font-weight:600;";
- headerEl.textContent = `Schema (${this.events.length} events, ${keys.length} fields)`;
+ headerEl.textContent = `${this.events.length} events, ${keys.length} fields. ${
+ allPresent
+ ? "Every field is present in every event."
+ : "Fields without a percentage are present in every event."
+ }`;
schema.appendChild(headerEl);
- for (const key of keys) {
- const row = document.createElement("div");
- row.style.cssText = "display:flex;gap:0.5rem;padding:0.15rem 0;";
+ // One grid for every field, so name, type and presence line up across
+ // rows whether or not a row has a presence cell.
+ const fields = document.createElement("div");
+ fields.className = "schema-fields";
+ for (const [key, typeInfo] of infos) {
const keyName = document.createElement("span");
- keyName.style.cssText = "color:var(--accent);min-width:200px;";
+ keyName.className = "schema-key";
keyName.textContent = key;
- row.appendChild(keyName);
+ fields.appendChild(keyName);
- const typeInfo = this.getTypeInfo(key);
const typeName = document.createElement("span");
- typeName.style.cssText = "color:var(--text-dim);min-width:100px;";
typeName.textContent = typeInfo.type;
- row.appendChild(typeName);
+ fields.appendChild(typeName);
- const presence = document.createElement("span");
- presence.style.cssText = "color:var(--text-dim);";
- presence.textContent = `${typeInfo.presence}% present`;
- row.appendChild(presence);
-
- schema.appendChild(row);
+ if (!typeInfo.inEveryEvent) {
+ const presence = document.createElement("span");
+ presence.textContent = `${typeInfo.presence}% present`;
+ fields.appendChild(presence);
+ }
}
+ schema.appendChild(fields);
this.container.appendChild(schema);
}
@@ -78,7 +86,11 @@ export class SchemaViewer {
return keys;
}
- private getTypeInfo(key: string): { type: string; presence: number } {
+ private getTypeInfo(key: string): {
+ type: string;
+ presence: number;
+ inEveryEvent: boolean;
+ } {
let present = 0;
const types = new Set();
@@ -93,7 +105,11 @@ export class SchemaViewer {
}
const typeStr = Array.from(types).join(" | ");
- const pct = Math.round((present / this.events.length) * 100);
- return { type: typeStr, presence: pct };
+ const inEveryEvent = present === this.events.length;
+ // A field missing from some events never rounds up to 100%.
+ const pct = inEveryEvent
+ ? 100
+ : Math.min(99, Math.round((present / this.events.length) * 100));
+ return { type: typeStr, presence: pct, inEveryEvent };
}
}
diff --git a/src/export/TimelineExport.ts b/src/export/TimelineExport.ts
index 68f6940..8fd8936 100644
--- a/src/export/TimelineExport.ts
+++ b/src/export/TimelineExport.ts
@@ -2,6 +2,12 @@ import type { EvidenceEvent } from "../types.js";
export type ExportFormat = "json" | "jsonl" | "csv";
+const FORMATS: [ExportFormat, string][] = [
+ ["json", "JSON"],
+ ["jsonl", "JSONL"],
+ ["csv", "CSV"],
+];
+
export class TimelineExport {
private container: HTMLElement;
private events: EvidenceEvent[] = [];
@@ -18,24 +24,26 @@ export class TimelineExport {
private render(): void {
this.container.innerHTML = "";
- const jsonBtn = document.createElement("button");
- jsonBtn.className = "export-btn";
- jsonBtn.textContent = "Export JSON";
- jsonBtn.addEventListener("click", () => this.export("json"));
-
- const jsonlBtn = document.createElement("button");
- jsonlBtn.className = "export-btn";
- jsonlBtn.textContent = "Export JSONL";
- jsonlBtn.addEventListener("click", () => this.export("jsonl"));
+ // One export action with a format choice, instead of a button per format.
+ const format = document.createElement("select");
+ format.setAttribute("aria-label", "Export format");
+ for (const [value, label] of FORMATS) {
+ const option = document.createElement("option");
+ option.value = value;
+ option.textContent = label;
+ format.appendChild(option);
+ }
- const csvBtn = document.createElement("button");
- csvBtn.className = "export-btn";
- csvBtn.textContent = "Export CSV";
- csvBtn.addEventListener("click", () => this.export("csv"));
+ const exportBtn = document.createElement("button");
+ exportBtn.type = "button";
+ exportBtn.className = "export-btn";
+ exportBtn.textContent = "Export";
+ exportBtn.addEventListener("click", () =>
+ this.export(format.value as ExportFormat),
+ );
- this.container.appendChild(jsonBtn);
- this.container.appendChild(jsonlBtn);
- this.container.appendChild(csvBtn);
+ this.container.appendChild(format);
+ this.container.appendChild(exportBtn);
const exportStatus = document.createElement("span");
exportStatus.id = "export-status";
diff --git a/src/main.ts b/src/main.ts
index 75987ee..cba61a7 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -19,7 +19,8 @@ let allEvents: EvidenceEvent[] = [];
const timelineView = new TimelineView({
container: document.getElementById("panel-timeline")!,
onSelectEvent: (event) => {
- diffView.showDiff(event);
+ if (event) diffView.showDiff(event);
+ else diffView.clear();
},
});
diff --git a/src/styles/main.css b/src/styles/main.css
index b870686..a495de4 100644
--- a/src/styles/main.css
+++ b/src/styles/main.css
@@ -76,17 +76,6 @@ body {
font-size: 0.8rem;
}
-.section-head {
- margin: 1.5rem 0 0.75rem;
- font-family: var(--font-heading);
- font-size: 0.85rem;
- font-weight: 600;
- color: var(--text-dim);
- text-transform: uppercase;
- letter-spacing: 0.12em;
- border-bottom: 1px solid var(--border);
- padding-bottom: 0.4rem;
-}
#app-header .subtitle {
margin-left: auto;
}
@@ -102,6 +91,7 @@ body {
#toolbar > div {
display: flex;
+ flex-wrap: wrap;
gap: 0.5rem;
align-items: center;
}
@@ -130,51 +120,40 @@ body {
}
/* ── Dashboard Grid ── */
+/* Panels take the height of what they hold; none is stretched to match a
+ taller neighbour. */
#dashboard {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
+ align-items: start;
}
#panel-timeline {
- grid-column: 1 / 2;
- grid-row: 1 / 3;
max-height: 80vh;
overflow-y: auto;
}
-#panel-diff {
- grid-column: 2 / 3;
- grid-row: 1 / 2;
-}
-#panel-citations {
- grid-column: 2 / 3;
- grid-row: 2 / 3;
-}
-#panel-certainty {
- grid-column: 1 / 3;
- grid-row: 3 / 4;
+/* Diff, citation churn and model confidence stack beside the timeline. */
+#dashboard-side {
+ display: flex;
+ flex-direction: column;
+ gap: 1rem;
+ min-width: 0;
}
+/* Three equal columns: the disputes charts fit one column, and the source
+ changes table needs the width a quarter of the row could not give it. */
#dashboard-row2 {
display: grid;
- grid-template-columns: 1fr 1fr 1fr 1fr;
+ grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 1rem;
margin-top: 1rem;
+ align-items: start;
}
-#panel-disputes {
- grid-column: 1 / 3;
-}
-#panel-wording {
- grid-column: 3 / 4;
-}
-#panel-source {
- grid-column: 4 / 5;
-}
#panel-schema {
- grid-column: 1 / 5;
- grid-row: 2 / 3;
+ grid-column: 1 / -1;
}
/* ── Buttons & Inputs ── */
@@ -199,10 +178,10 @@ button:hover {
border-color: var(--border-strong);
}
-button.active {
- background: var(--accent-soft);
- color: var(--accent);
- border-color: rgba(124, 138, 255, 0.35);
+button:disabled {
+ opacity: 0.4;
+ cursor: default;
+ pointer-events: none;
}
select {
@@ -312,10 +291,33 @@ select {
}
/* ── Diff Viewer ── */
+/* One run of text: unchanged, removed and added words flow inline. */
.diff-container {
- display: grid;
- grid-template-columns: 1fr 1fr;
- gap: 0.5rem;
+ font-size: 0.85rem;
+ line-height: 1.7;
+ white-space: pre-wrap;
+ word-break: break-word;
+}
+
+.diff-word-added {
+ background: var(--green-soft);
+ color: var(--green);
+ border-radius: 2px;
+}
+
+.diff-word-removed {
+ background: var(--red-soft);
+ color: var(--red);
+ text-decoration: line-through;
+ border-radius: 2px;
+}
+
+.diff-legend {
+ display: flex;
+ gap: 1rem;
+ margin-top: 0.75rem;
+ font-size: 0.75rem;
+ color: var(--text-dim);
}
.diff-pane {
@@ -472,6 +474,20 @@ select {
background: var(--bg-hover);
}
+/* Source changes: the short columns shrink to their content so the Detail
+ column, the table's only prose, gets the rest of the row. */
+.source-table th:not(:last-child),
+.source-table td:not(:last-child) {
+ width: 1%;
+ white-space: nowrap;
+}
+
+.source-table td:last-child {
+ font-family: var(--font);
+ font-size: 0.8rem;
+ overflow-wrap: anywhere;
+}
+
/* ── Cards ── */
.card-grid {
display: grid;
@@ -557,6 +573,7 @@ select {
border-radius: 4px;
padding: 0.2rem 0.5rem;
font-size: 0.72rem;
+ font-weight: 400;
cursor: pointer;
transition:
background 0.15s ease,
@@ -579,37 +596,30 @@ select {
color: var(--text-dim);
}
+/* Name, type, and a presence cell only on fields some events lack. Each
+ name starts a new row. */
+.schema-fields {
+ display: grid;
+ grid-template-columns:
+ minmax(min-content, 200px) minmax(min-content, 100px)
+ auto;
+ justify-content: start;
+ column-gap: 0.5rem;
+ row-gap: 0.3rem;
+}
+
+.schema-key {
+ grid-column: 1;
+ color: var(--accent);
+}
+
/* ── Responsive ── */
@media (max-width: 900px) {
#dashboard {
grid-template-columns: 1fr;
}
- #panel-timeline,
- #panel-diff,
- #panel-citations,
- #panel-certainty {
- grid-column: 1 / 2;
- }
- #panel-timeline {
- grid-row: auto;
- }
- #panel-diff {
- grid-row: auto;
- }
- #panel-citations {
- grid-row: auto;
- }
- #panel-certainty {
- grid-row: auto;
- }
#dashboard-row2 {
grid-template-columns: 1fr;
}
- #panel-disputes,
- #panel-wording,
- #panel-source,
- #panel-schema {
- grid-column: 1 / 2;
- }
}
diff --git a/src/timeline/DiffView.ts b/src/timeline/DiffView.ts
index 63918be..0bfa130 100644
--- a/src/timeline/DiffView.ts
+++ b/src/timeline/DiffView.ts
@@ -104,10 +104,11 @@ export class DiffView {
title.textContent = "Diff";
this.container.appendChild(title);
+ // The timeline selects its first listed event, so this shows only when
+ // no event is loaded or none matches the filter.
const hint = document.createElement("div");
- hint.style.cssText =
- "color:var(--text-dim);font-size:0.85rem;padding:1rem 0;";
- hint.textContent = "Click an event in the timeline to see the diff.";
+ hint.style.cssText = "color:var(--text-dim);font-size:0.85rem;";
+ hint.textContent = "No event selected.";
this.container.appendChild(hint);
}
@@ -138,28 +139,21 @@ export class DiffView {
for (const line of diffLines) {
const el = document.createElement("span");
- el.style.cssText =
- "font-family:var(--font-mono);font-size:0.85rem;line-height:1.7;";
if (line.type === "added") {
el.className = "diff-word-added";
- el.style.cssText +=
- "background:var(--green-soft);color:var(--green);padding:0.1rem 0;border-radius:2px;";
} else if (line.type === "removed") {
el.className = "diff-word-removed";
- el.style.cssText +=
- "background:var(--red-soft);color:var(--red);text-decoration:line-through;padding:0.1rem 0;border-radius:2px;";
}
el.textContent = line.content;
diffContainer.appendChild(el);
}
+ this.container.appendChild(diffContainer);
+
const labels = document.createElement("div");
- labels.style.cssText =
- "display:flex;gap:1rem;margin-top:0.75rem;font-size:0.75rem;color:var(--text-dim);";
+ labels.className = "diff-legend";
labels.innerHTML =
'⬤ Removed⬤ Added';
- diffContainer.appendChild(labels);
-
- this.container.appendChild(diffContainer);
+ this.container.appendChild(labels);
}
}
diff --git a/src/timeline/EventFilter.ts b/src/timeline/EventFilter.ts
index 39956ac..a885e79 100644
--- a/src/timeline/EventFilter.ts
+++ b/src/timeline/EventFilter.ts
@@ -57,27 +57,33 @@ export class EventFilter {
render(): void {
this.container.innerHTML = "";
+ // All and None are actions, not states: the chips already show which
+ // types are on, so neither button is highlighted. Each is disabled when
+ // it would change nothing.
const selectAll = document.createElement("button");
+ selectAll.type = "button";
selectAll.textContent = "All";
- selectAll.className =
- this.activeTypes.size === this.allTypes.length ? "active" : "";
+ selectAll.disabled = this.activeTypes.size === this.allTypes.length;
selectAll.addEventListener("click", () => this.selectAll());
this.container.appendChild(selectAll);
const clearBtn = document.createElement("button");
+ clearBtn.type = "button";
clearBtn.textContent = "None";
- clearBtn.className = this.activeTypes.size === 0 ? "active" : "";
+ clearBtn.disabled = this.activeTypes.size === 0;
clearBtn.addEventListener("click", () => this.clearAll());
this.container.appendChild(clearBtn);
for (const type of this.allTypes) {
- const tag = document.createElement("span");
+ const tag = document.createElement("button");
+ tag.type = "button";
tag.className = "filter-tag";
- if (this.activeTypes.has(type)) {
+ const active = this.activeTypes.has(type);
+ if (active) {
tag.classList.add("active");
}
+ tag.setAttribute("aria-pressed", String(active));
tag.textContent = formatEventTypeShort(type);
- tag.title = formatEventTypeShort(type);
tag.addEventListener("click", () => this.toggleType(type));
this.container.appendChild(tag);
}
diff --git a/src/timeline/TimelineView.ts b/src/timeline/TimelineView.ts
index bbbdc82..c3a4003 100644
--- a/src/timeline/TimelineView.ts
+++ b/src/timeline/TimelineView.ts
@@ -2,15 +2,18 @@ import type { EventType, EvidenceEvent } from "../types.js";
export interface TimelineViewOptions {
container: HTMLElement;
- onSelectEvent?: (event: EvidenceEvent) => void;
+ /** Called with the selected event, or null when no event is listed. */
+ onSelectEvent?: (event: EvidenceEvent | null) => void;
}
export class TimelineView {
private container: HTMLElement;
private events: EvidenceEvent[] = [];
private filtered: EvidenceEvent[] = [];
- private selectedId: string | null = null;
- private onSelectEvent?: (event: EvidenceEvent) => void;
+ // Held by reference: eventId is optional in the schema, and filtering keeps
+ // the same objects.
+ private selected: EvidenceEvent | null = null;
+ private onSelectEvent?: (event: EvidenceEvent | null) => void;
constructor(opts: TimelineViewOptions) {
this.container = opts.container;
@@ -20,17 +23,35 @@ export class TimelineView {
setData(events: EvidenceEvent[]): void {
this.events = events;
this.filtered = [...events];
+ this.syncSelection();
this.render();
}
applyFilter(filtered: EvidenceEvent[]): void {
this.filtered = filtered;
+ this.syncSelection();
this.render();
}
selectEvent(eventId: string | null): void {
- this.selectedId = eventId;
+ this.selected =
+ this.filtered.find(
+ (e) => e.eventId !== undefined && e.eventId === eventId,
+ ) ?? null;
this.render();
+ this.onSelectEvent?.(this.selected);
+ }
+
+ /**
+ * Keep the selected event while it is still listed; otherwise select the
+ * first listed one, so the diff panel shows a change instead of opening
+ * empty. The first listed event is the one at the top of this panel, next
+ * to the diff it fills.
+ */
+ private syncSelection(): void {
+ if (this.selected && this.filtered.includes(this.selected)) return;
+ this.selected = this.filtered[0] ?? null;
+ this.onSelectEvent?.(this.selected);
}
render(): void {
@@ -62,7 +83,7 @@ export class TimelineView {
for (const event of this.filtered) {
const item = document.createElement("div");
item.className = `timeline-item event-${event.eventType}`;
- if (event.eventId && event.eventId === this.selectedId) {
+ if (event === this.selected) {
item.classList.add("selected");
}
@@ -86,7 +107,7 @@ export class TimelineView {
item.appendChild(meta);
item.addEventListener("click", () => {
- this.selectedId = event.eventId ?? null;
+ this.selected = event;
this.render();
this.onSelectEvent?.(event);
});