From ccf0983cbc7dcf849c90e0dd48581100a4a09b9c Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 04:29:43 +0000 Subject: [PATCH] fix(ui): open the viewer on the timeline and the selected event's diff The first screen was spent on controls and an empty panel. This makes the viewer lead with the timeline and a filled diff, and gives each panel the room its content needs. - Toolbar: the filter row wraps (flex-wrap on #toolbar > div), so a 390px page is 390px wide, not 708px, and all nine type filters are on screen. The three export buttons become one Export button with a format select. - Filters: All and None are actions, not states. They are no longer highlighted beside nine highlighted chips; each is disabled when it would change nothing. The chips are buttons with aria-pressed, so they can be reached and toggled from the keyboard. - Diff: the timeline selects its first listed event on load, and again when a filter hides the selected one, so the diff panel shows a change instead of "Click an event". Selection is held by reference, since eventId is optional. The diff now reads as one run of text. Before, its word chunks were laid out in a two-column grid. - Layout: diff, citation churn and model confidence stack beside the timeline, so the confidence chart is no longer in a 1398px panel. The second row has three equal columns, and no panel is stretched to a taller neighbour's height. - Source changes: the Rev column moves to each row's tooltip. The short columns shrink to their content, so Detail gets the rest of the row: 210px at 1440, up from 26px visible. The panel is 245px tall, not 886px. Cells are built with textContent. They were built with innerHTML, which ran markup from an uploaded file. - Event schema: "present in every event" is said once in the header, and only fields some events lack show a percentage, which never rounds up to 100%. The fields share one grid so their columns stay aligned. - The "Timeline" and "Changes" labels above panels that say the same thing are removed. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01EpGaiBuSk5nCSV2hKyD6PN --- index.html | 10 +- src/citations/SourceChangeTable.ts | 24 +++-- src/export/SchemaViewer.ts | 52 +++++++---- src/export/TimelineExport.ts | 40 ++++---- src/main.ts | 3 +- src/styles/main.css | 144 +++++++++++++++-------------- src/timeline/DiffView.ts | 22 ++--- src/timeline/EventFilter.ts | 18 ++-- src/timeline/TimelineView.ts | 33 +++++-- 9 files changed, 204 insertions(+), 142 deletions(-) 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); });