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); });