(null);
+ const entries = useMemo(() => selectLocalLogEntries(log, query, status, phase, outcome), [log, query, status, phase, outcome]);
+ const decisionEntries = useMemo(() => selectLocalLogEntries(log, "", null, null, null), [log]);
+ const outcomes = useMemo(() => [...new Set(log.timeline.decisions.map(item => item.sections!.decision!.outcome))], [log]);
+ const labels = useMemo(() => [...new Set(log.entries.map(entry => entry.event.status))], [log]);
+ const selected = entries.find(entry => entry.line === selectedLine) ?? entries[0];
+ const position = selected ? entries.indexOf(selected) : -1;
+ const page = Math.max(0, Math.floor(position / 50));
+ const pages = Math.ceil(entries.length / 50);
+ const {timeline, history} = log;
+ // Reuse the existing codec for Explore-authored section labels only. Other
+ // summaries stay raw; the complete source text is always available unchanged.
+ const sections = selected ? log.findingsByEventId.get(selected.event.event_id)?.sections : null;
+ const decision = sections?.decision;
+ const outcomeLabel = (value: string) => Object.hasOwn(c.outcomes, value) ? c.outcomes[value as keyof typeof c.outcomes] : value;
+ const maxBin = Math.max(1, ...timeline.bins.map(bin => bin.events));
+ const date = (value: number | null) => value === null ? c.unknown : new Intl.DateTimeFormat(language === "zh" ? "zh-CN" : "en-GB",
+ {year: "numeric", month: "short", day: "numeric", hour: "2-digit", minute: "2-digit", timeZone: "UTC", timeZoneName: "short"}).format(value);
+ function move(index: number) {
+ setSelectedLine(entries[index]!.line);
+ window.requestAnimationFrame(() => original.current?.focus());
+ }
+ function changeView(value: undefined | null) {
+ const visible = selectLocalLogEntries(log, query, status, phase, value);
+ setSelectedLine(visible.some(entry => entry.line === selected?.line) ? selected!.line : visible[0]?.line ?? null);
+ setOutcome(value);
+ }
+ function reset() { setQuery(""); setStatus(null); setPhase(null); setOutcome(outcome === undefined ? undefined : null); setSelectedLine(null); search.current?.focus(); }
+
+ return <>
+
+
{c.span}: {timeline.start === null ? c.unknown : <> — >}
+ {timeline.bins.length > 0 &&
}
+
+
+
+
+ {outcome !== undefined && outcomes.length > 0 && }
+
+ {c.filters}
+
+
+
+
+ {c.match}: {entries.length} {c.of} {log.entries.length}
+ {(query || status !== null || phase !== null || typeof outcome === "string") && }
+ {!selected ? {outcome !== undefined && !decisionEntries.length ? c.noDecisions : c.noMatch}
+ {outcome !== undefined && !decisionEntries.length ?
:
}
:
+
+
+ {sections ? c.structured : c.original} · {selected.event.status || c.emptyLabel} · {selected.event.event_kind}
+ {selected.event.title}
+ {decision &&
+ {c.outcome} · {outcomeLabel(decision.outcome)}
+ {(["changed", "stopped", "retained"] as const).map(key => decision[key].length > 0 && - {c[key]}
{decision[key].map((item, index) => - {item}
)}
)}
+ {![...decision.changed, ...decision.stopped, ...decision.retained].length && {c.noChanges}
}
+ {c.decisionBoundary}
+ }
+ {sections ?
+ {decision ?
{c.context}
- {c.inputRevision}
- {sections.inputRevision}
- {c.applicability}
- {sections.applicability}
- {c.observation}
- {sections.observation}
: <>
{c.observation}
{sections.observation}
+
{c.applicability}
{sections.applicability}
+
{c.interpretation}
{sections.interpretation}
>}
+
{c.fullOriginal}
{selected.event.summary}
: {selected.event.summary}
}
+ {c.details}
+ - {c.line}
- {selected.line}
- Goal
- {log.goalId}
- Result
- {selected.event.result_id}
- Event
- {selected.event.event_id}
+ {selected.event.evidence_refs.length > 0 && <>{c.references}
{selected.event.evidence_refs.map((ref, index) => {ref} )}
>}
+ {c.boundary}
{c.coverage}
{c.limits}
+ {c.raw}
{JSON.stringify(selected.source, null, 2)}
+
+
+
{position + 1}/{entries.length}
+
+ {entries.slice(page * 50, (page + 1) * 50).map(entry => )}
+
{pages > 1 &&
+ {c.page} {page + 1}/{pages}
}
+
}
+ {c.boundary}{history.unknownTimeCount > 0 && <> {history.unknownTimeCount} {c.unknownEvents}>}
+ >;
+}
diff --git a/apps/presentation/site/src/time-machine/TimeMachine.tsx b/apps/presentation/site/src/time-machine/TimeMachine.tsx
index 69ec43e2d0..f9140c5721 100644
--- a/apps/presentation/site/src/time-machine/TimeMachine.tsx
+++ b/apps/presentation/site/src/time-machine/TimeMachine.tsx
@@ -1,8 +1,9 @@
import {useEffect, useRef, useState} from "react";
-import {ArrowLeft, ArrowRight, ArrowUpRight} from "lucide-react";
+import {ArrowLeft, ArrowRight, ArrowUpRight, FolderOpen} from "lucide-react";
import {usePublicPageNavigation} from "../usePublicPageNavigation";
import {captureRevision, datasetUrl, readPinnedCapture, type CapturedRecord} from "./records";
import scenes from "./scenes.json";
+import LocalLogReader from "./LocalLogReader";
const strings = {
en: {
@@ -20,6 +21,7 @@ const strings = {
sources: "Fixed public source", fixture: "This exact capture", totals: "76 + 134 + 105 = 315 Explore records. Record counts are not experiment counts.",
close: "Back to the question", noJs: "Enable JavaScript to make choices and browse records. The fixed public archive is also available directly.",
lesson: "What stays with the next decision?",
+ openLog: "Open your research log", localStage: "Your research record",
},
zh: {
back: "LoopX 首页", name: "时光机",
@@ -33,6 +35,7 @@ const strings = {
cursor: "原始记录序号", record: "记录", previous: "上一条记录", following: "下一条记录", original: "原文 · 未翻译", identifiers: "来源标识",
sources: "固定版本公开来源", fixture: "这份原始记录", totals: "76 + 134 + 105 = 315 条 Explore 记录,记录数量不是实验次数。",
close: "回到问题", noJs: "启用 JavaScript 后可做选择、浏览原记录,也可以直接打开固定版本的公开来源。", lesson: "下一次判断,能留下什么?",
+ openLog: "打开你的研究日志", localStage: "你的研究记录",
},
};
const readCase = () => {
@@ -48,6 +51,11 @@ export default function TimeMachine() {
const [failedFor, setFailed] = useState(null);
const [attempt, setAttempt] = useState(0);
const [position, setPosition] = useState(0);
+ const [upload, setUpload] = useState<{file: File; version: number} | null>(null);
+ const fileInput = useRef(null);
+ const openLog = useRef(null);
+ const fileVersion = useRef(0);
+ const localReading = upload !== null;
const resultTitle = useRef(null);
const questionTitle = useRef(null);
const reader = useRef(null);
@@ -75,6 +83,7 @@ export default function TimeMachine() {
let active = true;
setLoaded(null); setFailed(null); setPosition(0);
if (reader.current) reader.current.open = false;
+ if (localReading) return () => {active = false; abort.abort();};
void (async () => {
const response = await fetch(fileUrl, {signal: abort.signal});
if (!response.ok) throw new Error(`Capture HTTP ${response.status}`);
@@ -82,7 +91,7 @@ export default function TimeMachine() {
if (active) setLoaded({id: scene.id, rows});
})().catch(() => {if (active) setFailed(scene.id);});
return () => {active = false; abort.abort();};
- }, [fileUrl, scene, attempt]);
+ }, [fileUrl, scene, attempt, localReading]);
useEffect(() => {
if (chosen && records && pendingFocus.current) {
@@ -116,7 +125,7 @@ export default function TimeMachine() {
}
return
- IF YOU WERE THERE
{c.title}
{c.deck}
+
{
+ const file = event.target.files?.[0];
+ event.target.value = ""; // Selecting the same file again is a real retry.
+ if (!file) return; // Cancelling the chooser leaves the current reading intact.
+ fileVersion.current += 1;
+ setUpload({file, version: fileVersion.current});
+ window.scrollTo({top: 0, behavior: "instant"});
+ }}/>
+ {upload ?
fileInput.current?.click()} onClose={() => {
+ setUpload(null);
+ window.requestAnimationFrame(() => openLog.current?.focus());
+ }}/> : <>
+ IF YOU WERE THERE
{c.title}
{c.deck}
+
{c.count} {Object.keys(answers).length} / 3
;
}
diff --git a/apps/presentation/site/src/time-machine/local-log.ts b/apps/presentation/site/src/time-machine/local-log.ts
new file mode 100644
index 0000000000..4d92b322e5
--- /dev/null
+++ b/apps/presentation/site/src/time-machine/local-log.ts
@@ -0,0 +1,81 @@
+/** Local-file adapter to Explore's existing display owners. This accepts records
+ * for reading; it does not verify their claims or grant curated scene authority. */
+import {parseExploreHistoryPage, projectExploreHistory, type ExploreHistoryEvent} from "../../../../../loopx/control_plane/presentation/explore_history.ts";
+import {buildExploreTimeline, exploreTimelinePhaseAt} from "../../../../../loopx/control_plane/presentation/explore_timeline.ts";
+
+// Bound synchronous browser parsing and the shared timeline's in-memory arrays.
+// Reject the entire file at the limit; never publish a silently shortened log.
+export const LOCAL_LOG_MAX_BYTES = 20 * 1024 * 1024;
+export const LOCAL_LOG_MAX_RECORDS = 20_000;
+// File-intake UI errors only; these do not extend Explore's event/status vocabulary.
+export type LocalLogIssue = "size" | "empty" | "encoding" | "json" | "record" | "identity" | "read";
+export class LocalLogError extends Error {
+ readonly issue: LocalLogIssue;
+ readonly line: number | null;
+ constructor(issue: LocalLogIssue, line: number | null = null) {
+ super(issue); this.issue = issue; this.line = line;
+ }
+}
+export type LocalLogEntry = {line: number; event: ExploreHistoryEvent; source: Record};
+export type LocalExploreLog = ReturnType;
+
+export function readLocalExploreLog(bytes: ArrayBuffer) {
+ if (bytes.byteLength > LOCAL_LOG_MAX_BYTES) throw new LocalLogError("size");
+ let text: string;
+ try { text = new TextDecoder("utf-8", {fatal: true}).decode(bytes); }
+ catch { throw new LocalLogError("encoding"); }
+ const entries: LocalLogEntry[] = [];
+ let goalId = "";
+ let offset = 0, lineNumber = 0;
+ // Blank lines are legal. Walk them without allocating an array containing
+ // millions of empty entries for a newline-only file within the byte limit.
+ while (offset <= text.length) {
+ const end = text.indexOf("\n", offset);
+ const line = text.slice(offset, end < 0 ? text.length : end);
+ offset = end < 0 ? text.length + 1 : end + 1;
+ lineNumber++;
+ if (!line.trim()) continue;
+ if (entries.length === LOCAL_LOG_MAX_RECORDS) throw new LocalLogError("size");
+ let source: Record;
+ try { source = JSON.parse(line); }
+ catch { throw new LocalLogError("json", lineNumber); }
+ if (!source || Array.isArray(source) || typeof source !== "object" ||
+ typeof source.goal_id !== "string" || !source.goal_id.trim()) throw new LocalLogError("record", lineNumber);
+ if (!goalId) goalId = source.goal_id;
+ let event: ExploreHistoryEvent;
+ try {
+ event = parseExploreHistoryPage({ok: true, goal_id: goalId, items: [source], total: 1, next_cursor: null}, goalId).items[0]!;
+ } catch { throw new LocalLogError("record", lineNumber); }
+ entries.push({line: lineNumber, event, source});
+ }
+ if (!entries.length) throw new LocalLogError("empty");
+ let history: ReturnType;
+ try { history = projectExploreHistory(entries.map(entry => entry.event), goalId); }
+ catch { throw new LocalLogError("identity"); }
+ const timeline = buildExploreTimeline(history.nodes, history.edges, history.events);
+ const findingsByEventId = new Map(timeline.findings.map(item => [item.event.event_id, item]));
+ return {goalId, entries, history, timeline, findingsByEventId};
+}
+
+/** Filter source records without changing append order, labels or time semantics.
+ * decisionOutcome is a local reading filter: undefined reads every record,
+ * null reads every explicitly decoded decision, and a string matches its
+ * source outcome verbatim. This does not classify prose or node updates. */
+export function selectLocalLogEntries(log: LocalExploreLog, query: string, status: string | null, phase: number | "unknown" | null, decisionOutcome?: string | null) {
+ const needle = query.trim().toLowerCase();
+ return log.entries.filter(entry => {
+ const event = entry.event;
+ if (decisionOutcome !== undefined) {
+ const decision = log.findingsByEventId.get(event.event_id)?.sections?.decision;
+ if (!decision || (decisionOutcome !== null && decision.outcome !== decisionOutcome)) return false;
+ }
+ if (status !== null && event.status !== status) return false;
+ if (phase !== null) {
+ const parsed = Date.parse(event.recorded_at);
+ const at = Number.isFinite(parsed) ? parsed : null;
+ if (phase === "unknown" ? at !== null : exploreTimelinePhaseAt(log.timeline.phases, at) !== phase) return false;
+ }
+ return !needle || [event.title, event.summary, event.result_id, event.event_id, ...event.evidence_refs]
+ .some(value => value.toLowerCase().includes(needle));
+ });
+}
diff --git a/apps/presentation/site/src/time-machine/time-machine.css b/apps/presentation/site/src/time-machine/time-machine.css
index d6bed02d9b..f9b29e6abf 100644
--- a/apps/presentation/site/src/time-machine/time-machine.css
+++ b/apps/presentation/site/src/time-machine/time-machine.css
@@ -65,6 +65,67 @@
.tm-takeaway a { color: var(--blue); display: inline-flex; gap: 8px; align-items: center; min-height: 44px; font-size: 14px; }
.tm-footer { display: flex; gap: 20px; justify-content: space-between; align-items: center; padding: 20px 0 0; border-top: 1px solid var(--border); color: var(--body); font-size: 12px; }
.tm-footer a { display: inline-flex; align-items: center; gap: 4px; min-height: 44px; text-decoration: underline; text-underline-offset: 3px; }
+.tm-open-log, .tm-log button { display: inline-flex; align-items: center; gap: 8px; min-height: 44px; border: 0; background: none; color: var(--blue); font: inherit; font-size: 13px; text-align: left; cursor: pointer; padding: 8px 0; }
+.tm-open-log { margin-top: 12px; }
+.tm-log-format { font: 12px/1.5 var(--font-mono); color: var(--body); }
+.tm-log-heading { display: flex; justify-content: space-between; align-items: start; gap: 32px; }
+.tm-log h1 { font-size: 24px; font-weight: 500; letter-spacing: -.025em; line-height: 1.4; margin: 0; min-width: 0; }
+.tm-log h1:focus { outline: none; }
+.tm-log-file { display: flex; align-items: baseline; gap: 8px; overflow-wrap: anywhere; color: var(--ink); }
+.tm-log-file svg { flex-shrink: 0; }
+.tm-log-actions { display: flex; align-items: center; gap: 24px; flex-shrink: 0; }
+.tm-log-actions button:last-child { color: var(--body); }
+.tm-log-privacy { font-size: 12px; color: var(--body); margin: 20px 0 0; }
+.tm-log-overview { border-block: 1px solid var(--border); padding: 20px 0 0; margin-top: 20px; }
+.tm-log-overview>p { font-size: 13px; color: var(--body); margin: 0 0 8px; }
+.tm-log-strip { display: block; width: 100%; height: 40px; margin-top: 16px; fill: var(--body); opacity: .65; }
+.tm-log-views { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; margin-top: 16px; }
+.tm-log-views>button { padding-inline: 12px; color: var(--body); border-radius: 6px; }
+.tm-log-views>button[aria-pressed=true] { color: var(--ink); background: var(--soft); }
+.tm-log-views>button span { font: 12px/1.5 var(--font-mono); }
+.tm-log-views>label { display: flex; align-items: center; gap: 8px; margin-left: auto; font-size: 12px; color: var(--body); min-width: 0; }
+.tm-log-views select { font: inherit; font-size: 14px; min-height: 44px; border: 1px solid var(--border); border-radius: 6px; background: var(--surface); color: var(--ink); padding: 8px; max-width: 100%; }
+.tm-log-filter-disclosure { margin-top: 12px; font-size: 13px; color: var(--body); }
+.tm-log-filter-disclosure>summary { min-height: 44px; align-content: center; cursor: pointer; width: fit-content; }
+.tm-log-filter-disclosure .tm-log-filters { padding-top: 8px; }
+.tm-log-filters { display: grid; grid-template-columns: minmax(0, 2fr) minmax(0, 1fr) minmax(0, 1fr); gap: 16px; padding-top: 24px; }
+.tm-log-filters label { display: grid; gap: 8px; font-size: 12px; color: var(--body); min-width: 0; }
+.tm-log-filters :is(input, select) { font: inherit; font-size: 14px; width: 100%; min-width: 0; min-height: 44px; border: 1px solid var(--border); border-radius: 6px; background: var(--surface); color: var(--ink); padding: 10px 12px; }
+.tm-log-match { display: flex; flex-wrap: wrap; align-items: center; gap: 0 6px; font-size: 12px; color: var(--body); margin: 12px 0; min-height: 44px; }
+.tm-log-match button { margin-left: auto; }
+.tm-log-reading { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 2fr); gap: 28px; align-items: start; }
+.tm-log-index { min-width: 0; grid-column: 1; grid-row: 1; }
+.tm-log-index ol { list-style: none; padding: 0; margin: 0; max-height: 520px; overflow-y: auto; overscroll-behavior: contain; scrollbar-gutter: stable; }
+.tm-log-index li { border-bottom: 1px solid var(--border); }
+.tm-log-index li button { display: grid; gap: 4px; width: 100%; color: var(--body); padding: 12px; border-left: 2px solid transparent; overflow-wrap: anywhere; }
+.tm-log-index li button[aria-current=true] { border-left-color: var(--ink); background: var(--soft); color: var(--ink); }
+.tm-log-row-meta { font: 12px/1.6 var(--font-mono); color: var(--body); }
+.tm-log-detail { min-width: 0; grid-column: 2; grid-row: 1; }
+.tm-log-detail h2 { font-size: 20px; line-height: 1.5; margin: 12px 0; scroll-margin-top: 24px; }
+.tm-log-finding>p { font-size: 14px; line-height: 1.8; color: var(--body); margin: 8px 0 20px; white-space: pre-wrap; }
+.tm-log-finding>p.tm-log-field-label { font-size: 12px; margin: 16px 0 4px; }
+.tm-log-finding>p.tm-log-observation { color: var(--ink); }
+.tm-log-decision { border-top: 1px solid var(--border); padding-top: 12px; }
+.tm-log-decision-outcome { font-size: 14px; color: var(--body); margin: 0 0 16px; }
+.tm-log-decision-outcome strong { color: var(--ink); font-weight: 500; }
+.tm-log-decision dl { margin: 0; }
+.tm-log-decision dl>div { display: grid; grid-template-columns: 104px minmax(0, 1fr); gap: 16px; padding-block: 12px; }
+.tm-log-decision dt { font-size: 12px; color: var(--body); padding-top: 2px; }
+.tm-log-decision dd { margin: 0; font-size: 14px; line-height: 1.8; white-space: pre-wrap; }
+.tm-log-decision ul { padding: 0; margin: 0; list-style: none; }
+.tm-log-decision li+li { margin-top: 8px; }
+.tm-log-decision-note { font-size: 12px; color: var(--body); margin: 16px 0; }
+.tm-log-context dl { display: grid; gap: 8px; font-size: 14px; line-height: 1.8; }
+.tm-log-context dt { font-size: 12px; color: var(--body); }
+.tm-log-context dd { margin: 0 0 12px; white-space: pre-wrap; }
+.tm-log-source dl { display: grid; grid-template-columns: auto minmax(0, 1fr); gap: 8px 16px; font: 12px/1.7 var(--font-mono); }
+.tm-log-source dd { margin: 0; }
+.tm-log-source ul { padding-left: 20px; }
+.tm-log-source pre { white-space: pre-wrap; overflow-wrap: anywhere; font: 12px/1.8 var(--font-mono); }
+.tm-log-record-nav, .tm-log-pagination { display: flex; align-items: center; justify-content: space-between; gap: 8px; color: var(--body); font: 12px/1.5 var(--font-mono); }
+.tm-log-message { padding: 24px 0; font-size: 14px; color: var(--body); }
+.tm-log-error [role=alert] { color: var(--danger); }
+.tm-log-note { font-size: 12px; color: var(--body); border-top: 1px solid var(--border); padding-top: 16px; margin-top: 24px; }
@keyframes tm-appear { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: none; } }
@media (prefers-reduced-motion: reduce) { .tm-page *, html:has(.tm-page) { animation: none; scroll-behavior: auto; } }
@media (max-width: 700px) {
@@ -86,4 +147,23 @@
.tm-boundary { flex-direction: column; gap: 12px; }
.tm-footer { flex-direction: column; align-items: start; gap: 4px; }
.tm-original { padding: 20px; }
+ .tm-log-heading { display: flex; gap: 12px; }
+ .tm-log-actions { flex-direction: row; gap: 0; }
+ .tm-log-actions button { min-width: 44px; justify-content: center; }
+ .tm-log-actions button>span { display: none; }
+ .tm-log-privacy { margin-top: 12px; }
+ .tm-log-overview { padding-top: 12px; margin-top: 16px; }
+ .tm-log-strip { height: 24px; margin-top: 12px; }
+ .tm-log-filters { grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 12px; padding-top: 16px; }
+ .tm-log-match { margin-block: 8px; }
+ .tm-log-filters>label:first-child { grid-column: 1 / -1; }
+ .tm-log-views { gap: 4px; margin-top: 12px; }
+ .tm-log-views>label { width: 100%; margin-top: 4px; }
+ .tm-log-views select { flex: 1; min-width: 0; }
+ .tm-log-decision dl>div { grid-template-columns: minmax(0, 1fr); gap: 4px; }
+ .tm-log-reading { grid-template-columns: minmax(0, 1fr); gap: 20px; }
+ .tm-log-index, .tm-log-detail { grid-column: auto; grid-row: auto; }
+ .tm-log h1 { font-size: 20px; }
+ .tm-log-index ol { max-height: 200px; }
+ .tm-log-overview .tm-log-span time { display: inline-block; }
}
diff --git a/apps/presentation/site/tsconfig.json b/apps/presentation/site/tsconfig.json
index 86df527c8a..cfe3b0de3d 100644
--- a/apps/presentation/site/tsconfig.json
+++ b/apps/presentation/site/tsconfig.json
@@ -12,6 +12,7 @@
"forceConsistentCasingInFileNames": true,
"module": "ESNext",
"moduleResolution": "Bundler",
+ "allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
diff --git a/examples/dashboard-frontstage-browser-smoke.mjs b/examples/dashboard-frontstage-browser-smoke.mjs
index dbdbc9384a..b699dd8089 100644
--- a/examples/dashboard-frontstage-browser-smoke.mjs
+++ b/examples/dashboard-frontstage-browser-smoke.mjs
@@ -118,6 +118,138 @@ async function assertTimeMachineJourney(page, lang) {
await page.locator(".tm-choices button:not([disabled])").first().click();
await page.locator(".tm-revealed").waitFor();
assert.match(await page.locator(".tm-revealed").innerText(), /13\.84/);
+ await assertLocalTimeMachineLog(page, lang);
+}
+
+async function assertLocalTimeMachineLog(page, lang) {
+ const row = (extra = {}) => ({schema_version: "loopx_explore_result_event_v0", goal_id: "public-local-example",
+ event_id: "question", result_id: "question", event_kind: "node", node_kind: "question", status: "open",
+ title: "Synthetic question", summary: "Source text, not a scored result.", recorded_at: "2026-01-01T00:00:00Z", ...extra});
+ const negative = "Original counterevidence literal, not HTML";
+ const records = [row(), row({event_id: "early-negative", result_id: "finding", event_kind: "finding", node_id: "question", status: "refuted",
+ title: "Earlier counterevidence", summary: negative, evidence_refs: ["https://example.invalid/do-not-fetch-local-evidence"], recorded_at: "2026-01-01T04:00:00Z"}),
+ row({event_id: "revision", result_id: "finding", event_kind: "finding", node_id: "question", title: "Later tentative revision", status: "tentative", recorded_at: "2026-01-01T18:00:00Z"}),
+ row({event_id: "backfill", status: "exploring", recorded_at: "2026-01-01T03:00:00Z"}),
+ row({event_id: "unattached", result_id: "unattached", event_kind: "finding", node_id: "missing", title: "Unattached finding", status: "refuted", recorded_at: "unknown"})];
+ const file = (name, rows = records) => ({name, mimeType: "application/x-ndjson", buffer: Buffer.from(rows.map(row => JSON.stringify(row)).join("\n"))});
+ const input = page.locator("#tm-log-file");
+ await page.evaluate(() => {
+ window.__tmLocalAudit = {requests: [], writes: []};
+ const audit = window.__tmLocalAudit;
+ const fetch = window.fetch;
+ window.fetch = function (...args) { audit.requests.push(String(args[0])); return fetch.apply(this, args); };
+ const send = XMLHttpRequest.prototype.send;
+ XMLHttpRequest.prototype.send = function (...args) { audit.requests.push("xhr"); return send.apply(this, args); };
+ const beacon = navigator.sendBeacon.bind(navigator);
+ navigator.sendBeacon = (...args) => { audit.requests.push(String(args[0])); return beacon(...args); };
+ const set = Storage.prototype.setItem;
+ Storage.prototype.setItem = function (...args) { audit.writes.push(String(args[0])); return set.apply(this, args); };
+ const open = indexedDB.open.bind(indexedDB);
+ indexedDB.open = (...args) => { audit.writes.push("indexedDB"); return open(...args); };
+ });
+ // The ordinary entry opens the actual browser file chooser, not a command-copy path.
+ const chooser = page.waitForEvent("filechooser");
+ await page.locator("#tm-open-log").focus();
+ await page.locator("#tm-open-log").press("Enter");
+ await (await chooser).setFiles(file("research.jsonl"));
+ await page.locator(".tm-log-reading").waitFor();
+ assert.equal(await page.locator(".tm-log-match strong").textContent(), "5");
+ assert.match(await page.locator(".tm-log-span").textContent(), /00:00.*18:00/);
+ assert.equal(await page.locator(".tm-revealed,.tm-metrics,.tm-source").count(), 0, "local bytes inherit no curated claim");
+ await page.locator(".tm-log-filter-disclosure > summary").click();
+ await page.locator("#tm-log-label").selectOption(JSON.stringify("refuted"));
+ assert.equal(await page.locator(".tm-log-match strong").textContent(), "2");
+ assert.equal(await page.locator(".tm-log-detail .tm-record-body").textContent(), negative);
+ assert.equal(await page.locator(".tm-record-body b").count(), 0);
+ await page.locator(".tm-log-source > summary").click();
+ assert.equal(await page.locator(".tm-log-source a").count(), 0, "external evidence references remain text");
+ await page.locator(".tm-log-source > summary").click();
+ await page.locator("#tm-log-search").fill("absent phrase");
+ assert.equal(await page.locator(".tm-log-reading").count(), 0, "no stale original when filters have no match");
+ await page.locator(".tm-log-match button").click();
+ assert.equal(await page.evaluate(() => document.activeElement.id), "tm-log-search");
+ await page.locator("#tm-log-time").selectOption("unknown");
+ assert.equal(await page.locator(".tm-log-match strong").textContent(), "1");
+ assert.equal(await page.locator(".tm-log-detail h2").textContent(), "Unattached finding");
+ await input.setInputFiles({name: "bad.jsonl", mimeType: "application/x-ndjson", buffer: Buffer.from(`${JSON.stringify(records[0])}\n{broken`)});
+ await page.locator(".tm-log-error [role=alert]").waitFor();
+ assert.match(await page.locator(".tm-log-error [role=alert]").textContent(), /2/);
+ assert.equal(await page.locator(".tm-log-reading").count(), 0);
+ await input.setInputFiles(file("research.jsonl"));
+ await page.locator(".tm-log-reading").waitFor();
+ await input.setInputFiles(file("research.jsonl"));
+ await page.locator(".tm-log-reading").waitFor();
+ await page.evaluate(() => {
+ const read = File.prototype.arrayBuffer;
+ File.prototype.arrayBuffer = function () {
+ if (this.name === "slow.jsonl") return new Promise((resolve, reject) => { window.__tmReleaseSlow = () => read.call(this).then(resolve, reject); });
+ return read.call(this);
+ };
+ });
+ await input.setInputFiles(file("slow.jsonl"));
+ await page.waitForFunction(() => typeof window.__tmReleaseSlow === "function");
+ await input.setInputFiles(file("newer.jsonl", [row({title: "New file only"})]));
+ await page.locator(".tm-log-reading").waitFor();
+ await page.evaluate(() => window.__tmReleaseSlow());
+ assert.equal(await page.locator(".tm-log-detail h2").textContent(), "New file only");
+ const structured = "Input revision: synthetic-v2 Applicability: this fixture only Observation: The new input refutes the earlier route. Interpretation: Outcome: continue\nretained:\n- original scope\nstopped:\n- the failed route";
+ await input.setInputFiles(file("structured.jsonl", [row({event_id: "structured", event_kind: "finding", status: "refuted", summary: structured})]));
+ await page.locator(".tm-log-decision").waitFor();
+ assert.match(await page.locator(".tm-log-decision").textContent(), /original scope/);
+ assert.match(await page.locator(".tm-log-decision").textContent(), /the failed route/);
+ await page.locator(".tm-log-context > summary").click();
+ assert.match(await page.locator(".tm-log-context").textContent(), /this fixture only.*The new input refutes the earlier route\./s);
+ await page.locator(".tm-log-finding > details:not(.tm-log-context) > summary").click();
+ assert.equal(await page.locator(".tm-record-body").textContent(), structured, "structured reading does not replace the complete original text");
+
+ const portfolio = JSON.parse(await readFile(resolve(exportSite, "lab/time-machine/data/portfolio.json"), "utf8"));
+ await input.setInputFiles(file("public-research.jsonl", portfolio.events));
+ await page.locator(".tm-log-decision").waitFor();
+ assert.equal(await page.locator(".tm-log-match strong").textContent(), "37");
+ await page.locator("#tm-log-outcome").selectOption(JSON.stringify("replan"));
+ assert.equal(await page.locator(".tm-log-match strong").textContent(), "29");
+ await page.locator("#tm-log-outcome").selectOption(JSON.stringify("continue"));
+ assert.equal(await page.locator(".tm-log-match strong").textContent(), "8");
+ await page.locator(".tm-log-filter-disclosure > summary").click();
+ await page.locator("#tm-log-search").fill("absent-decision-text");
+ assert.equal(await page.locator(".tm-log-reading").count(), 0, "filtered decision cannot leave a stale readout");
+ await page.locator(".tm-log-match button").click();
+ assert.equal(await page.locator(".tm-log-match strong").textContent(), "37");
+ for (let index = 0; index < 37; index++) {
+ await page.locator(".tm-log-index li button").nth(index).click();
+ await page.waitForFunction(() => document.querySelector(".tm-log-detail h2") === document.activeElement);
+ const source = page.locator(".tm-log-source");
+ if (!await source.getAttribute("open").then(value => value !== null)) await source.locator(":scope > summary").click();
+ const line = Number(await source.locator("dd").first().textContent());
+ const original = page.locator(".tm-log-finding > details:not(.tm-log-context)");
+ if (!await original.getAttribute("open").then(value => value !== null)) await original.locator(":scope > summary").click();
+ assert.equal(await page.locator(".tm-record-body").textContent(), portfolio.events[line - 1].summary, "each decision returns to its exact source row");
+ }
+ const allRecords = page.locator(".tm-log-views > button").first();
+ await allRecords.focus(); await allRecords.press("Enter");
+ assert.equal(await page.locator(".tm-log-match strong").textContent(), "76");
+ assert.equal(await page.locator("#tm-log-outcome").count(), 0);
+ const unknown = structured.replace("Outcome: continue", "Outcome: __proto__");
+ await input.setInputFiles(file("unknown.jsonl", [row({event_id: "unknown-decision", event_kind: "finding", summary: unknown})]));
+ await page.locator(".tm-log-decision").waitFor();
+ assert.match(await page.locator(".tm-log-decision-outcome").textContent(), /__proto__/);
+ await page.locator("#tm-log-outcome").selectOption(JSON.stringify("__proto__"));
+ assert.equal(await page.locator(".tm-log-match strong").textContent(), "1");
+ await input.setInputFiles(file("no-decisions.jsonl"));
+ await page.locator(".tm-log-reading").waitFor();
+ await page.locator(".tm-log-views > button").nth(1).click();
+ assert.equal(await page.locator(".tm-log-reading").count(), 0);
+ assert.equal(await page.locator("#tm-log-outcome").count(), 0);
+ await page.locator(".tm-log-message button").click();
+ assert.equal(await page.locator(".tm-log-match strong").textContent(), "5");
+ assert.deepEqual(await page.evaluate(() => window.__tmLocalAudit), {requests: [], writes: []});
+ assert.ok(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1), "local reader fits the viewport");
+ await page.locator(".tm-log-actions button").last().click();
+ await page.waitForFunction(() => document.activeElement.id === "tm-open-log");
+ await page.locator(".tm-revealed").waitFor();
+ assert.equal(await page.locator(".tm-log").count(), 0);
+ assert.match(await page.locator(".tm-revealed").innerText(), /13\.84/);
+ assert.equal(await page.locator("html").getAttribute("lang"), lang === "zh" ? "zh-CN" : "en");
}
let browser;