diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a07afc8..4f41a47 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,10 +22,14 @@ jobs: cache-dependency-path: package.json - name: Install dependencies run: npm install --ignore-scripts + - name: Version consistency + run: npm run check:version - name: Unit tests run: npm test - name: Web build run: npm run build + - name: Dependency audit (critical only) + run: npm audit --audit-level=critical windows-installer: name: Windows NSIS installer @@ -41,11 +45,27 @@ jobs: workspaces: src-tauri - name: Install frontend dependencies run: npm install --ignore-scripts + - name: Version consistency + run: npm run check:version - name: Build NSIS installer run: npm run tauri build -- --bundles nsis - - name: Upload Windows installer + - name: Validate installer and write checksum + shell: pwsh + run: | + $files = @(Get-ChildItem "src-tauri/target/release/bundle/nsis/*.exe") + if ($files.Count -ne 1) { throw "Expected exactly one NSIS installer, found $($files.Count)." } + $installer = $files[0] + if ($installer.Length -lt 500000) { throw "Installer is unexpectedly small: $($installer.Length) bytes." } + $hash = (Get-FileHash $installer.FullName -Algorithm SHA256).Hash.ToLowerInvariant() + "$hash $($installer.Name)" | Set-Content -Encoding ascii "src-tauri/target/release/bundle/nsis/installer-sha256.txt" + Write-Host "Validated installer: $($installer.Name) ($($installer.Length) bytes)" + Write-Host "SHA-256: $hash" + - name: Upload validated Windows installer uses: actions/upload-artifact@v4 with: name: open-document-understanding-windows-nsis - path: src-tauri/target/release/bundle/nsis/*.exe + path: | + src-tauri/target/release/bundle/nsis/*.exe + src-tauri/target/release/bundle/nsis/installer-sha256.txt if-no-files-found: error + retention-days: 90 diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 439010f..b0ad83d 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -25,17 +25,20 @@ Desktop application │ └── document retrieval ├── Persistence │ ├── settings +│ ├── durable per-document sidecars │ ├── model metadata -│ └── reversible sidecar annotations +│ └── reversible annotations/provenance └── Optional network verifier └── explicit outbound requests only ``` ## Application shell -The planned desktop shell is Tauri 2 with a TypeScript/React front end. This gives the project a modern document interface while preserving a native desktop packaging path. +The desktop shell is Tauri 2 with a TypeScript/React front end. This gives the project a modern document interface while preserving a native desktop packaging path. -PDF rendering should use PDF.js or an equivalent permissively licensed renderer suitable for selectable-text overlays. +PDF rendering uses PDF.js with a canvas plus selectable text overlay. + +The application UI supports persistent Light/Dark mode. The rendered PDF itself is not recolored or rewritten by theme changes; dark mode changes only the reader chrome/background so the document remains visually faithful. ## Local engine @@ -53,33 +56,42 @@ This keeps future replacement with ONNX Runtime, C++, Rust, llama.cpp-class runt ## Source anchoring -A source anchor should identify enough information to restore the user to the exact context used for an answer. +Beginning in v0.0.2, source identity uses cryptographic hashes rather than the fast UI fingerprint used by v0.0.1. -Initial anchor fields should include: +A source anchor contains: -- document fingerprint; -- page number; -- selected source text; -- optional text-item/block identifiers; -- bounding box(es) when reliable; -- normalized source hash. +- SHA-256 of the complete source document bytes; +- one-based page number; +- normalized selected source text; +- SHA-256 of the normalized source quote; +- PDF text-item start/end indices; +- start/end character offsets; +- creation time; +- schema/version information. -Anchors should survive ordinary UI actions and be stored separately from the source document. +Restoration first attempts the stored text-item range. If those renderer boundaries have shifted, the page text layer is searched for the same normalized quote and the recovered range is highlighted. The original PDF remains unchanged. ## Document sidecars -The original file should remain byte-for-byte untouched unless the user deliberately exports a derivative document. +The original file remains byte-for-byte untouched unless the user deliberately exports a derivative document. + +v0.0.2 introduces a versioned JSON sidecar model. In the installed Tauri application, sidecars are stored under the application's data directory in a dedicated `sidecars` directory, keyed by the source document's SHA-256. The browser-development fallback uses local storage only for development convenience. -Generated material should be stored in a sidecar format that can contain: +A sidecar can contain records for: -- source-document fingerprint; -- anchors; +- source selections; - translations; - explanations; +- questions/answers; - user notes/corrections; - model/version provenance; - domain-pack provenance; -- verification citations/status. +- verification citations/status; +- human-review status. + +v0.0.2 writes source-selection records only; future AI outputs must attach to the same provenance model rather than inventing a parallel store. + +The sidecar JSON format is described by `schemas/document-sidecar.schema.json`. The sidecar must not be assumed legally safe to redistribute merely because it does not contain the original PDF. @@ -143,17 +155,21 @@ Engineering requirements include: - explicit visible network state; - deterministic logging of externally transmitted verification queries where reasonable; - minimal permissions; -- dependency review for unexpected network behavior. +- dependency review for unexpected network behavior; +- document and sidecar size limits at the native boundary; +- SHA-256 source identity for provenance. -## Packaging +## Packaging and release validation -The application should be packaged from early development. +The application is packaged from early development. Reference distribution path: -`source -> CI build -> platform bundle/installer -> clean-machine smoke test` +`source -> CI checks -> platform bundle/installer -> checksum -> clean-machine smoke test` + +Windows is the initial reference platform. CI verifies application-version consistency, runs tests/builds, produces exactly one NSIS installer, applies a minimum-size sanity check, calculates SHA-256 and uploads the installer with its checksum manifest. The clean-machine GUI workflow is documented separately in `RELEASE_CHECKLIST.md`. -Windows is the initial reference platform. Linux and macOS follow once the Windows vertical slice is stable. Android is a first-class future platform, especially for camera/SOP/accessibility use cases. +Linux and macOS follow once the Windows vertical slice is stable. Android is a first-class future platform, especially for camera/SOP/accessibility use cases. ## Architectural decision discipline diff --git a/README.md b/README.md index 7555ee6..1f63441 100644 --- a/README.md +++ b/README.md @@ -8,19 +8,24 @@ A privacy-first, open-source document reader whose goal is simple: The project started from a medical-education use case but is intentionally generic. Medical science will become an optional domain pack rather than the core application. -## Current milestone: v0.0.1 +## Current milestone: v0.0.2 -The first milestone is intentionally foundational: +v0.0.1 proved that the application can be built, installed and used on a clean Windows machine. v0.0.2 hardens the provenance layer before any local AI is introduced. -- desktop application shell using Tauri 2; -- selectable-text PDF opening and rendering using PDF.js; -- paragraph/text selection; -- a source-anchored side panel with **Translate / Explain / Ask** modes; -- no AI model yet; -- no OCR yet; -- no document upload at runtime. +Current capabilities include: -The point is to prove the product shell, packaging boundary, source anchoring and privacy model before adding model complexity. +- Tauri 2 + React/TypeScript desktop application; +- local selectable-text PDF opening and PDF.js rendering; +- source passage selection and return-to-source highlighting; +- SHA-256 identity for source documents and normalized source quotes; +- durable per-document JSON sidecars stored in application data; +- fallback source restoration when PDF text-item boundaries shift; +- visible **Translate / Explain / Ask** modes, still deliberately non-generative; +- persistent Light/Dark mode; +- visible **LOCAL ONLY** state; +- Windows NSIS packaging with CI-generated SHA-256 installer manifest. + +There is still **no AI model, OCR or runtime internet verification path** in v0.0.2. ## Product principles @@ -34,11 +39,11 @@ The point is to prove the product shell, packaging boundary, source anchoring an ## Development policy -`PROJECT_MANDATE.md` is the standing execution mandate for AI-assisted development. `PRODUCT_SPEC.md` and `ARCHITECTURE.md` are the product and engineering sources of truth. +`PROJECT_MANDATE.md` is the standing execution mandate for AI-assisted development. `PRODUCT_SPEC.md`, `ARCHITECTURE.md`, `ROADMAP.md` and `RELEASE_CHECKLIST.md` are project sources of truth. ## Status -This repository is at the product-shell stage. Do not use it for clinical decisions, safety-critical instructions or authoritative translation yet. +This repository is in the provenance-hardening stage. Do not use it for clinical decisions, safety-critical instructions or authoritative translation yet. ## License diff --git a/RELEASE_CHECKLIST.md b/RELEASE_CHECKLIST.md new file mode 100644 index 0000000..2b9dd56 --- /dev/null +++ b/RELEASE_CHECKLIST.md @@ -0,0 +1,43 @@ +# Release validation checklist + +A build is not a release merely because CI compiled it. This checklist defines the evidence required before calling a Windows milestone validated. + +## Automated checks + +- [ ] application/package/Tauri versions agree (`npm run check:version`) +- [ ] unit tests pass +- [ ] production web build passes +- [ ] dependency audit has no critical npm advisory +- [ ] Windows Tauri build succeeds +- [ ] exactly one NSIS installer is produced +- [ ] installer is larger than the minimum sanity threshold +- [ ] CI emits a SHA-256 checksum manifest beside the installer +- [ ] CI uploads the validated installer + checksum as one artifact + +## Clean-machine Windows smoke test + +Use a Windows machine/environment that is not the CI workspace and does not rely on developer tooling. + +1. Install the generated NSIS `.exe`. +2. Launch Open Document Understanding from the installed application. +3. Confirm the displayed application version matches the release version. +4. Open a selectable-text PDF from local storage. +5. Confirm the document renders and remains selectable. +6. Select a source passage and confirm the right panel shows the exact source. +7. Change page/zoom, then use **Return to source** and confirm the source is restored/highlighted. +8. Close the application, reopen it, reopen the same PDF, and confirm the source record restores from the local sidecar. +9. Toggle dark mode, restart the application, and confirm the theme preference persists. +10. Confirm the application still displays **LOCAL ONLY** and that no online verification feature is present in this milestone. + +## Evidence to record + +For each validated milestone, record in the issue or release notes: + +- CI run identifier; +- installer filename; +- installer SHA-256; +- clean-machine operating system; +- smoke-test result; +- known limitations. + +Do not claim a clean-machine validation unless the GUI workflow was actually exercised. diff --git a/ROADMAP.md b/ROADMAP.md index 2efd931..3ee7af6 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,34 +2,40 @@ This roadmap is intentionally milestone-based rather than date-based. Each milestone should produce something testable and closer to a distributable application. -## v0.0.1 — Product shell +## v0.0.1 — Product shell ✅ Goal: prove the application boundary before adding AI complexity. +Completed and clean-machine validated on Windows: + - Tauri 2 desktop shell; - React/TypeScript UI; - open local selectable-text PDF; - PDF rendering with text selection; - source side panel; -- Translate / Explain / Ask controls present but allowed to be stubbed; +- Translate / Explain / Ask controls present but stubbed; - local/network status indicator; - basic settings storage; - CI build workflow; -- Windows packaging path; -- synthetic test documents. - -Exit criterion: the application can be installed on a second Windows machine, opens a PDF, and records a stable source selection. +- Windows NSIS packaging; +- synthetic smoke-test document. -## v0.0.2 — Robust source anchoring +## v0.0.2 — Robust source anchoring and release hardening (current) +- SHA-256 document identity; +- SHA-256 normalized quote identity; - page/selection anchoring; -- normalized source hashes; +- fallback source-text range recovery if PDF text-item boundaries shift; - jump back to source; -- selection persistence; -- sidecar schema; -- regression tests for source anchoring. - -Exit criterion: a stored answer/annotation can reliably return the user to the exact source context after reopening the document. +- durable per-document JSON sidecars in application data storage; +- versioned sidecar schema for future Translate / Explain / Ask provenance; +- regression tests for hash stability, range recovery and sidecar records; +- persistent Light/Dark mode; +- cross-file version consistency checks; +- validated Windows installer checksum manifest; +- release-validation checklist. + +Exit criterion: a stored source/provenance record survives application restart, reopens against the same SHA-256-identified PDF, reliably restores/highlights its source context, and the v0.0.2 Windows installer passes the documented clean-machine validation including persistent dark mode. ## v0.1 — First local understanding loop diff --git a/docs/adr/0001-source-provenance-sidecars.md b/docs/adr/0001-source-provenance-sidecars.md new file mode 100644 index 0000000..4d8568b --- /dev/null +++ b/docs/adr/0001-source-provenance-sidecars.md @@ -0,0 +1,42 @@ +# ADR 0001: SHA-256 source identity and local provenance sidecars + +- Status: Accepted +- Milestone: v0.0.2 + +## Context + +Future Translate, Explain and Ask outputs must remain traceable to the exact source passage that produced them. The v0.0.1 reader used a fast non-cryptographic fingerprint suitable only for UI restoration and stored one last anchor in web storage. + +That is insufficient for durable provenance because it does not provide a strong document identity, does not support multiple records, and would encourage later AI features to invent their own persistence model. + +## Decision + +1. Identify the complete source document by SHA-256 of its bytes. +2. Normalize a selected source quote and identify that quote by SHA-256 as well. +3. Store page, PDF text-item range and character offsets in the source anchor. +4. If stored PDF text-item boundaries no longer reproduce the exact quote, search the page text layer for the same normalized quote and recover the highlight range. +5. Store document-associated provenance in a versioned JSON sidecar keyed by document SHA-256. +6. In the installed Tauri application, write sidecars to the application's data directory rather than modifying the PDF or writing beside a potentially read-only/copyrighted source file. +7. Reserve the same sidecar record model for future `translate`, `explain`, `ask` and `verify` operations. +8. Keep the browser-only development fallback in localStorage, but do not treat browser localStorage as the desktop durability mechanism. + +## Consequences + +### Positive + +- the original document remains byte-for-byte untouched; +- provenance is tied to a strong document identity; +- multiple source/AI records can coexist; +- future AI features inherit a provenance structure instead of adding one later; +- application upgrades can evolve the sidecar independently through schema versions. + +### Trade-offs + +- SHA-256 adds a small local hashing delay when opening very large PDFs; +- sidecars are currently local to the application installation and are not portable/exportable yet; +- exact restoration still depends on PDF text extraction quality; +- scanned/image-only PDFs require the later OCR anchoring design. + +## Explicit non-decision + +A sidecar containing a translation or explanation is not automatically safe to redistribute merely because the original PDF is absent. Copyright/licensing policy for exported/shared sidecars remains a separate product/legal decision. diff --git a/package.json b/package.json index 4234d2f..403bd8d 100644 --- a/package.json +++ b/package.json @@ -1,29 +1,30 @@ { "name": "open-document-understanding", "private": true, - "version": "0.0.1", + "version": "0.0.2", "type": "module", "scripts": { "dev": "vite --port 1420", "build": "tsc -b && vite build", "test": "vitest run", + "check:version": "node scripts/check-version.mjs", "tauri": "tauri" }, "dependencies": { - "@tauri-apps/api": "^2.8.0", - "@tauri-apps/plugin-dialog": "^2.4.0", - "pdfjs-dist": "^5.4.149", - "react": "^19.1.1", - "react-dom": "^19.1.1" + "@tauri-apps/api": "2.8.0", + "@tauri-apps/plugin-dialog": "2.4.0", + "pdfjs-dist": "5.4.149", + "react": "19.1.1", + "react-dom": "19.1.1" }, "devDependencies": { - "@tauri-apps/cli": "^2.8.4", - "@types/react": "^19.1.10", - "@types/react-dom": "^19.1.7", - "@vitejs/plugin-react": "^5.0.4", - "jsdom": "^27.0.0", - "typescript": "^5.9.2", - "vite": "^7.1.7", - "vitest": "^3.2.4" + "@tauri-apps/cli": "2.8.4", + "@types/react": "19.1.10", + "@types/react-dom": "19.1.7", + "@vitejs/plugin-react": "5.0.4", + "jsdom": "27.0.0", + "typescript": "5.9.2", + "vite": "7.3.6", + "vitest": "3.2.7" } } diff --git a/schemas/document-sidecar.schema.json b/schemas/document-sidecar.schema.json new file mode 100644 index 0000000..69b8ab4 --- /dev/null +++ b/schemas/document-sidecar.schema.json @@ -0,0 +1,67 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://open-document-understanding.local/schemas/document-sidecar.schema.json", + "title": "Open Document Understanding document sidecar", + "type": "object", + "required": ["schemaVersion", "document", "records", "createdAt", "updatedAt"], + "properties": { + "schemaVersion": { "const": 1 }, + "document": { + "type": "object", + "required": ["sha256", "fileName", "byteLength"], + "properties": { + "sha256": { "type": "string", "pattern": "^[a-f0-9]{64}$" }, + "fileName": { "type": "string", "minLength": 1 }, + "byteLength": { "type": "integer", "minimum": 0 } + }, + "additionalProperties": false + }, + "records": { + "type": "array", + "items": { + "type": "object", + "required": ["id", "operation", "anchor", "createdAt", "externallyVerified", "humanReviewed"], + "properties": { + "id": { "type": "string", "minLength": 1 }, + "operation": { "enum": ["source", "translate", "explain", "ask", "verify"] }, + "createdAt": { "type": "string" }, + "output": { "type": "string" }, + "question": { "type": "string" }, + "externallyVerified": { "type": "boolean" }, + "humanReviewed": { "type": "boolean" }, + "engine": { + "type": "object", + "required": ["id", "version", "local"], + "properties": { + "id": { "type": "string" }, + "version": { "type": "string" }, + "local": { "type": "boolean" } + }, + "additionalProperties": false + }, + "anchor": { + "type": "object", + "required": ["version", "documentSha256", "page", "quote", "quoteSha256", "startItem", "endItem", "startOffset", "endOffset", "createdAt"], + "properties": { + "version": { "const": 2 }, + "documentSha256": { "type": "string", "pattern": "^[a-f0-9]{64}$" }, + "page": { "type": "integer", "minimum": 1 }, + "quote": { "type": "string", "minLength": 1 }, + "quoteSha256": { "type": "string", "pattern": "^[a-f0-9]{64}$" }, + "startItem": { "type": "integer", "minimum": 0 }, + "endItem": { "type": "integer", "minimum": 0 }, + "startOffset": { "type": "integer", "minimum": 0 }, + "endOffset": { "type": "integer", "minimum": 0 }, + "createdAt": { "type": "string" } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + } + }, + "createdAt": { "type": "string" }, + "updatedAt": { "type": "string" } + }, + "additionalProperties": false +} diff --git a/scripts/check-version.mjs b/scripts/check-version.mjs new file mode 100644 index 0000000..28865e5 --- /dev/null +++ b/scripts/check-version.mjs @@ -0,0 +1,20 @@ +import fs from "node:fs"; + +const packageJson = JSON.parse(fs.readFileSync("package.json", "utf8")); +const tauriConfig = JSON.parse(fs.readFileSync("src-tauri/tauri.conf.json", "utf8")); +const cargoToml = fs.readFileSync("src-tauri/Cargo.toml", "utf8"); +const cargoVersion = cargoToml.match(/^version\s*=\s*"([^"]+)"/m)?.[1]; + +const versions = { + packageJson: packageJson.version, + tauriConfig: tauriConfig.version, + cargoToml: cargoVersion +}; + +const unique = new Set(Object.values(versions)); +if (unique.size !== 1 || unique.has(undefined)) { + console.error("Version mismatch detected:", versions); + process.exit(1); +} + +console.log(`Version consistency OK: ${packageJson.version}`); diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index ca5b357..ede76be 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "open-document-understanding" -version = "0.0.1" +version = "0.0.2" description = "Privacy-first local document understanding reader" authors = ["Open Document Understanding contributors"] edition = "2021" diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 9f10c60..cb733f7 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,6 +1,12 @@ use std::{fs, path::Path}; +use tauri::Manager; const MAX_PDF_BYTES: u64 = 512 * 1024 * 1024; +const MAX_SIDECAR_BYTES: usize = 10 * 1024 * 1024; + +fn valid_sha256(value: &str) -> bool { + value.len() == 64 && value.bytes().all(|byte| byte.is_ascii_hexdigit()) +} #[tauri::command] fn read_pdf(path: String) -> Result, String> { @@ -26,11 +32,69 @@ fn read_pdf(path: String) -> Result, String> { fs::read(path_ref).map_err(|error| format!("Cannot read PDF: {error}")) } +#[tauri::command] +fn load_sidecar(app: tauri::AppHandle, document_sha256: String) -> Result, String> { + if !valid_sha256(&document_sha256) { + return Err("Invalid document SHA-256.".into()); + } + + let directory = app + .path() + .app_data_dir() + .map_err(|error| format!("Cannot resolve app data directory: {error}"))? + .join("sidecars"); + let path = directory.join(format!("{document_sha256}.json")); + + if !path.exists() { + return Ok(None); + } + + let metadata = fs::metadata(&path).map_err(|error| format!("Cannot inspect sidecar: {error}"))?; + if metadata.len() > MAX_SIDECAR_BYTES as u64 { + return Err("Sidecar exceeds the current 10 MB safety limit.".into()); + } + + fs::read_to_string(path) + .map(Some) + .map_err(|error| format!("Cannot read sidecar: {error}")) +} + +#[tauri::command] +fn save_sidecar( + app: tauri::AppHandle, + document_sha256: String, + contents: String, +) -> Result<(), String> { + if !valid_sha256(&document_sha256) { + return Err("Invalid document SHA-256.".into()); + } + if contents.len() > MAX_SIDECAR_BYTES { + return Err("Sidecar exceeds the current 10 MB safety limit.".into()); + } + + let directory = app + .path() + .app_data_dir() + .map_err(|error| format!("Cannot resolve app data directory: {error}"))? + .join("sidecars"); + fs::create_dir_all(&directory) + .map_err(|error| format!("Cannot create sidecar directory: {error}"))?; + + let path = directory.join(format!("{document_sha256}.json")); + let temporary = directory.join(format!("{document_sha256}.json.tmp")); + fs::write(&temporary, contents).map_err(|error| format!("Cannot write sidecar: {error}"))?; + if path.exists() { + fs::remove_file(&path).map_err(|error| format!("Cannot replace sidecar: {error}"))?; + } + fs::rename(temporary, path).map_err(|error| format!("Cannot finalize sidecar: {error}"))?; + Ok(()) +} + #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { tauri::Builder::default() .plugin(tauri_plugin_dialog::init()) - .invoke_handler(tauri::generate_handler![read_pdf]) + .invoke_handler(tauri::generate_handler![read_pdf, load_sidecar, save_sidecar]) .run(tauri::generate_context!()) .expect("error while running Open Document Understanding"); } diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 1b814cd..715c813 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,8 +1,8 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "Open Document Understanding", - "version": "0.0.1", - "identifier": "org.opendocumentunderstanding.app", + "version": "0.0.2", + "identifier": "org.opendocumentunderstanding.desktop", "build": { "beforeDevCommand": "npm run dev", "devUrl": "http://localhost:1420", diff --git a/src/App.tsx b/src/App.tsx index df272e9..b1846a2 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -3,21 +3,31 @@ import { getDocument, type PDFDocumentProxy, type PDFPageProxy } from "pdfjs-dis import { invoke, isTauri } from "@tauri-apps/api/core"; import { open } from "@tauri-apps/plugin-dialog"; import { PdfPage } from "./PdfPage"; -import { anchorMatchesDocument, fingerprintBytes, type SourceAnchor } from "./sourceAnchor"; +import { anchorMatchesDocument, sha256Bytes, type SourceAnchor } from "./sourceAnchor"; +import { + addSourceRecord, + createDocumentSidecar, + latestSourceAnchor, + loadDocumentSidecar, + saveDocumentSidecar, + type DocumentSidecar +} from "./sidecar"; -const SETTINGS_KEY = "odu.settings.v1"; -const ANCHOR_KEY = "odu.last-anchor.v1"; +const SETTINGS_KEY = "odu.settings.v2"; type Mode = "translate" | "explain" | "ask"; +type Theme = "light" | "dark"; type Settings = { targetLanguage: string; explanationLevel: string; + theme: Theme; }; const defaultSettings: Settings = { targetLanguage: "Hindi", - explanationLevel: "Simple" + explanationLevel: "Simple", + theme: "light" }; function loadSettings(): Settings { @@ -28,23 +38,16 @@ function loadSettings(): Settings { } } -function loadAnchor(): SourceAnchor | null { - try { - const raw = localStorage.getItem(ANCHOR_KEY); - return raw ? (JSON.parse(raw) as SourceAnchor) : null; - } catch { - return null; - } -} - export default function App() { const [pdf, setPdf] = useState(null); const [page, setPage] = useState(null); const [pageNumber, setPageNumber] = useState(1); const [scale, setScale] = useState(1.15); const [fileName, setFileName] = useState(""); - const [fingerprint, setFingerprint] = useState(""); + const [documentSha256, setDocumentSha256] = useState(""); + const [documentByteLength, setDocumentByteLength] = useState(0); const [anchor, setAnchor] = useState(null); + const [sidecar, setSidecar] = useState(null); const [mode, setMode] = useState("explain"); const [question, setQuestion] = useState(""); const [settings, setSettings] = useState(() => loadSettings()); @@ -53,6 +56,7 @@ export default function App() { useEffect(() => { localStorage.setItem(SETTINGS_KEY, JSON.stringify(settings)); + document.documentElement.dataset.theme = settings.theme; }, [settings]); useEffect(() => { @@ -67,21 +71,32 @@ export default function App() { }, [pdf, pageNumber]); async function loadPdfBytes(bytes: Uint8Array, name: string) { - setStatus("Opening PDF locally…"); - const localFingerprint = fingerprintBytes(bytes); + setStatus("Hashing and opening PDF locally…"); + const sha256 = await sha256Bytes(bytes); const task = getDocument({ data: bytes }); const loaded = await task.promise; setPdf(loaded); setPageNumber(1); setPage(null); setFileName(name); - setFingerprint(localFingerprint); + setDocumentSha256(sha256); + setDocumentByteLength(bytes.byteLength); + + const storedSidecar = await loadDocumentSidecar(sha256); + const nextSidecar = + storedSidecar ?? + createDocumentSidecar({ + sha256, + fileName: name, + byteLength: bytes.byteLength + }); + setSidecar(nextSidecar); - const previous = loadAnchor(); - if (previous && anchorMatchesDocument(previous, localFingerprint)) { + const previous = latestSourceAnchor(nextSidecar); + if (previous && anchorMatchesDocument(previous, sha256)) { setAnchor(previous); setPageNumber(Math.min(Math.max(1, previous.page), loaded.numPages)); - setStatus("PDF opened locally. Previous source anchor restored."); + setStatus("PDF opened locally. Source anchor restored from durable local sidecar."); } else { setAnchor(null); setStatus("PDF opened locally. Select text to begin."); @@ -119,8 +134,21 @@ export default function App() { function rememberAnchor(next: SourceAnchor) { setAnchor(next); - localStorage.setItem(ANCHOR_KEY, JSON.stringify(next)); - setStatus(`Source anchored on page ${next.page}.`); + setSidecar((current) => { + const base = + current ?? + createDocumentSidecar({ + sha256: next.documentSha256, + fileName: fileName || "document.pdf", + byteLength: documentByteLength + }); + const updated = addSourceRecord(base, next); + void saveDocumentSidecar(updated).catch((error) => { + setStatus(`Source is selected, but its sidecar could not be saved: ${String(error)}`); + }); + return updated; + }); + setStatus(`Source anchored on page ${next.page} and saved locally.`); } function returnToSource() { @@ -129,6 +157,10 @@ export default function App() { setStatus(`Returned to anchored source on page ${anchor.page}.`); } + function toggleTheme() { + setSettings((current) => ({ ...current, theme: current.theme === "dark" ? "light" : "dark" })); + } + const placeholder = useMemo(() => { if (!anchor) return "Select text in the PDF. Your source passage will appear here before any AI is allowed to act on it."; if (mode === "translate") { @@ -137,21 +169,26 @@ export default function App() { if (mode === "ask") { return question.trim() ? "Document-grounded question engine not installed yet. The future answer will cite this exact source anchor." - : "Type a question about the selected passage. v0.0.1 deliberately does not invent an answer."; + : "Type a question about the selected passage. v0.0.2 deliberately does not invent an answer."; } - return `${settings.explanationLevel} explanation engine not installed yet. v0.0.1 proves the private reader and source-anchoring path first.`; + return `${settings.explanationLevel} explanation engine not installed yet. v0.0.2 hardens durable source provenance before local AI is added.`; }, [anchor, mode, question, settings]); + const savedSourceCount = sidecar?.records.filter((record) => record.operation === "source").length ?? 0; + return (
Open Document Understanding - v0.0.1 + v0.0.2
LOCAL ONLY
+ @@ -211,6 +248,11 @@ export default function App() { <>
Page {anchor.page} · items {anchor.startItem}–{anchor.endItem}
{anchor.quote}
+
+ Document SHA-256 {anchor.documentSha256.slice(0, 12)}… + Quote SHA-256 {anchor.quoteSha256.slice(0, 12)}… + {savedSourceCount} saved source {savedSourceCount === 1 ? "record" : "records"} +
) : ( diff --git a/src/PdfPage.tsx b/src/PdfPage.tsx index deb9070..bfbd916 100644 --- a/src/PdfPage.tsx +++ b/src/PdfPage.tsx @@ -1,7 +1,7 @@ -import { useEffect, useRef, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { Util, type PDFPageProxy } from "pdfjs-dist"; import type { SourceAnchor } from "./sourceAnchor"; -import { createSourceAnchor } from "./sourceAnchor"; +import { createSourceAnchor, resolveAnchorRange } from "./sourceAnchor"; type TextItemLike = { str: string; @@ -14,7 +14,7 @@ type Props = { page: PDFPageProxy; pageNumber: number; scale: number; - documentFingerprint: string; + documentSha256: string; activeAnchor: SourceAnchor | null; onAnchor: (anchor: SourceAnchor) => void; }; @@ -25,11 +25,18 @@ function closestTextItem(node: Node | null): HTMLElement | null { return element?.closest(".text-item") ?? null; } -export function PdfPage({ page, pageNumber, scale, documentFingerprint, activeAnchor, onAnchor }: Props) { +export function PdfPage({ page, pageNumber, scale, documentSha256, activeAnchor, onAnchor }: Props) { const canvasRef = useRef(null); const layerRef = useRef(null); const [items, setItems] = useState([]); const viewport = page.getViewport({ scale }); + const resolvedAnchor = useMemo( + () => + activeAnchor?.page === pageNumber + ? resolveAnchorRange(activeAnchor, items.map((item) => item.str)) + : null, + [activeAnchor, items, pageNumber] + ); useEffect(() => { const canvas = canvasRef.current; @@ -70,12 +77,12 @@ export function PdfPage({ page, pageNumber, scale, documentFingerprint, activeAn }, [page, scale]); useEffect(() => { - if (!activeAnchor || activeAnchor.page !== pageNumber || !layerRef.current) return; - const first = layerRef.current.querySelector(`[data-item-index="${activeAnchor.startItem}"]`); + if (!resolvedAnchor || !layerRef.current) return; + const first = layerRef.current.querySelector(`[data-item-index="${resolvedAnchor.startItem}"]`); first?.scrollIntoView({ behavior: "smooth", block: "center" }); - }, [activeAnchor, pageNumber, items]); + }, [resolvedAnchor]); - function captureSelection() { + async function captureSelection() { const selection = window.getSelection(); if (!selection || selection.isCollapsed || !layerRef.current) return; const range = selection.getRangeAt(0); @@ -89,8 +96,8 @@ export function PdfPage({ page, pageNumber, scale, documentFingerprint, activeAn const endItem = Number(endElement.dataset.itemIndex); if (!Number.isFinite(startItem) || !Number.isFinite(endItem)) return; - const anchor = createSourceAnchor({ - documentFingerprint, + const anchor = await createSourceAnchor({ + documentSha256, page: pageNumber, quote: selection.toString(), startItem: Math.min(startItem, endItem), @@ -102,7 +109,7 @@ export function PdfPage({ page, pageNumber, scale, documentFingerprint, activeAn } return ( -
+
void captureSelection()}>
{items.map((item, index) => { @@ -112,7 +119,7 @@ export function PdfPage({ page, pageNumber, scale, documentFingerprint, activeAn const left = tx[4]; const top = tx[5] - fontHeight; const anchored = - activeAnchor?.page === pageNumber && index >= activeAnchor.startItem && index <= activeAnchor.endItem; + resolvedAnchor !== null && index >= resolvedAnchor.startItem && index <= resolvedAnchor.endItem; return ( { + it("persists source provenance without modifying the document", async () => { + const sidecar = createDocumentSidecar({ + sha256: DOC_SHA, + fileName: "sample.pdf", + byteLength: 1234 + }); + const anchor = await createSourceAnchor({ + documentSha256: DOC_SHA, + page: 2, + quote: "source passage", + startItem: 3, + endItem: 4, + startOffset: 0, + endOffset: 7 + }); + + const updated = addSourceRecord(sidecar, anchor); + expect(updated.records).toHaveLength(1); + expect(updated.records[0].operation).toBe("source"); + expect(updated.records[0].externallyVerified).toBe(false); + expect(latestSourceAnchor(updated)?.quote).toBe("source passage"); + }); + + it("deduplicates the same source selection", async () => { + let sidecar = createDocumentSidecar({ sha256: DOC_SHA, fileName: "sample.pdf", byteLength: 1234 }); + const anchor = await createSourceAnchor({ + documentSha256: DOC_SHA, + page: 1, + quote: "same selection", + startItem: 0, + endItem: 1, + startOffset: 0, + endOffset: 4 + }); + sidecar = addSourceRecord(sidecar, anchor); + sidecar = addSourceRecord(sidecar, anchor); + expect(sidecar.records).toHaveLength(1); + }); + + it("rejects malformed serialized sidecars", () => { + expect(parseDocumentSidecar("not json")).toBeNull(); + expect(parseDocumentSidecar(JSON.stringify({ schemaVersion: 2 }))).toBeNull(); + }); +}); diff --git a/src/sidecar.ts b/src/sidecar.ts new file mode 100644 index 0000000..70bf51a --- /dev/null +++ b/src/sidecar.ts @@ -0,0 +1,123 @@ +import { invoke, isTauri } from "@tauri-apps/api/core"; +import type { SourceAnchor } from "./sourceAnchor"; + +const SIDECAR_PREFIX = "odu.sidecar.v1."; + +export type ProvenanceOperation = "source" | "translate" | "explain" | "ask" | "verify"; + +export type ProvenanceRecord = { + id: string; + operation: ProvenanceOperation; + anchor: SourceAnchor; + createdAt: string; + output?: string; + question?: string; + engine?: { + id: string; + version: string; + local: boolean; + }; + externallyVerified: boolean; + humanReviewed: boolean; +}; + +export type DocumentSidecar = { + schemaVersion: 1; + document: { + sha256: string; + fileName: string; + byteLength: number; + }; + records: ProvenanceRecord[]; + createdAt: string; + updatedAt: string; +}; + +export function sidecarStorageKey(documentSha256: string): string { + return `${SIDECAR_PREFIX}${documentSha256}`; +} + +export function createDocumentSidecar(document: DocumentSidecar["document"]): DocumentSidecar { + const now = new Date().toISOString(); + return { + schemaVersion: 1, + document, + records: [], + createdAt: now, + updatedAt: now + }; +} + +export function addSourceRecord(sidecar: DocumentSidecar, anchor: SourceAnchor): DocumentSidecar { + if (anchor.documentSha256 !== sidecar.document.sha256) { + throw new Error("Cannot attach an anchor to a different document sidecar."); + } + + const record: ProvenanceRecord = { + id: `source-${anchor.quoteSha256.slice(0, 12)}-${Date.now()}`, + operation: "source", + anchor, + createdAt: new Date().toISOString(), + externallyVerified: false, + humanReviewed: false + }; + + const records = [ + ...sidecar.records.filter( + (existing) => + !( + existing.operation === "source" && + existing.anchor.page === anchor.page && + existing.anchor.quoteSha256 === anchor.quoteSha256 + ) + ), + record + ]; + + return { + ...sidecar, + records, + updatedAt: record.createdAt + }; +} + +export function latestSourceAnchor(sidecar: DocumentSidecar): SourceAnchor | null { + const sources = sidecar.records.filter((record) => record.operation === "source"); + if (sources.length === 0) return null; + return sources[sources.length - 1].anchor; +} + +export function parseDocumentSidecar(value: string): DocumentSidecar | null { + try { + const parsed = JSON.parse(value) as DocumentSidecar; + if (parsed.schemaVersion !== 1) return null; + if (!parsed.document || !/^[a-f0-9]{64}$/.test(parsed.document.sha256)) return null; + if (!Array.isArray(parsed.records)) return null; + return parsed; + } catch { + return null; + } +} + +export async function loadDocumentSidecar(documentSha256: string): Promise { + if (isTauri()) { + const raw = await invoke("load_sidecar", { documentSha256 }); + return raw ? parseDocumentSidecar(raw) : null; + } + + const raw = localStorage.getItem(sidecarStorageKey(documentSha256)); + return raw ? parseDocumentSidecar(raw) : null; +} + +export async function saveDocumentSidecar(sidecar: DocumentSidecar): Promise { + const serialized = JSON.stringify(sidecar, null, 2); + if (isTauri()) { + await invoke("save_sidecar", { + documentSha256: sidecar.document.sha256, + contents: serialized + }); + return; + } + + localStorage.setItem(sidecarStorageKey(sidecar.document.sha256), serialized); +} diff --git a/src/sourceAnchor.test.ts b/src/sourceAnchor.test.ts index a5721ab..cbc0fd6 100644 --- a/src/sourceAnchor.test.ts +++ b/src/sourceAnchor.test.ts @@ -1,5 +1,14 @@ import { describe, expect, it } from "vitest"; -import { anchorMatchesDocument, createSourceAnchor, fingerprintBytes, normalizeQuote } from "./sourceAnchor"; +import { + anchorMatchesDocument, + createSourceAnchor, + normalizeQuote, + resolveAnchorRange, + sha256Bytes, + sha256Text +} from "./sourceAnchor"; + +const DOC_SHA = "a".repeat(64); describe("source anchors", () => { it("normalizes selected text without changing meaning", () => { @@ -8,9 +17,9 @@ describe("source anchors", () => { ); }); - it("creates a one-based page anchor", () => { - const anchor = createSourceAnchor({ - documentFingerprint: "doc-1", + it("creates a one-based SHA-256 source anchor", async () => { + const anchor = await createSourceAnchor({ + documentSha256: DOC_SHA, page: 4, quote: " sample text ", startItem: 2, @@ -20,15 +29,16 @@ describe("source anchors", () => { }); expect(anchor.quote).toBe("sample text"); expect(anchor.page).toBe(4); - expect(anchor.version).toBe(1); - expect(anchorMatchesDocument(anchor, "doc-1")).toBe(true); - expect(anchorMatchesDocument(anchor, "doc-2")).toBe(false); + expect(anchor.version).toBe(2); + expect(anchor.quoteSha256).toBe(await sha256Text("sample text")); + expect(anchorMatchesDocument(anchor, DOC_SHA)).toBe(true); + expect(anchorMatchesDocument(anchor, "b".repeat(64))).toBe(false); }); - it("rejects invalid ranges", () => { - expect(() => + it("rejects invalid ranges", async () => { + await expect( createSourceAnchor({ - documentFingerprint: "doc-1", + documentSha256: DOC_SHA, page: 1, quote: "text", startItem: 3, @@ -36,12 +46,44 @@ describe("source anchors", () => { startOffset: 0, endOffset: 1 }) - ).toThrow(); + ).rejects.toThrow(); }); - it("fingerprints identical bytes deterministically", () => { + it("hashes identical bytes deterministically with SHA-256", async () => { const bytes = new Uint8Array([1, 2, 3, 4, 5]); - expect(fingerprintBytes(bytes)).toBe(fingerprintBytes(bytes)); - expect(fingerprintBytes(bytes)).not.toBe(fingerprintBytes(new Uint8Array([1, 2, 3, 4, 6]))); + expect(await sha256Bytes(bytes)).toBe(await sha256Bytes(bytes)); + expect(await sha256Bytes(bytes)).not.toBe(await sha256Bytes(new Uint8Array([1, 2, 3, 4, 6]))); + }); + + it("restores a stored range directly when text items are stable", async () => { + const anchor = await createSourceAnchor({ + documentSha256: DOC_SHA, + page: 1, + quote: "beta gamma", + startItem: 1, + endItem: 2, + startOffset: 0, + endOffset: 5 + }); + expect(resolveAnchorRange(anchor, ["alpha", "beta", "gamma", "delta"])).toEqual({ + startItem: 1, + endItem: 2 + }); + }); + + it("recovers the quote if PDF text-item boundaries shift", async () => { + const anchor = await createSourceAnchor({ + documentSha256: DOC_SHA, + page: 1, + quote: "beta gamma", + startItem: 4, + endItem: 5, + startOffset: 0, + endOffset: 5 + }); + expect(resolveAnchorRange(anchor, ["alpha", "beta", "gamma", "delta"])).toEqual({ + startItem: 1, + endItem: 2 + }); }); }); diff --git a/src/sourceAnchor.ts b/src/sourceAnchor.ts index df39f8b..f51ed8c 100644 --- a/src/sourceAnchor.ts +++ b/src/sourceAnchor.ts @@ -1,8 +1,9 @@ export type SourceAnchor = { - version: 1; - documentFingerprint: string; + version: 2; + documentSha256: string; page: number; quote: string; + quoteSha256: string; startItem: number; endItem: number; startOffset: number; @@ -10,37 +11,71 @@ export type SourceAnchor = { createdAt: string; }; +export type ResolvedAnchorRange = { + startItem: number; + endItem: number; +}; + export function normalizeQuote(value: string): string { return value.replace(/\s+/g, " ").trim(); } -export function createSourceAnchor(input: Omit & { quote: string }): SourceAnchor { +function bytesToHex(bytes: Uint8Array): string { + return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join(""); +} + +export async function sha256Bytes(bytes: Uint8Array): Promise { + const stableBytes = Uint8Array.from(bytes); + const digest = await crypto.subtle.digest("SHA-256", stableBytes.buffer); + return bytesToHex(new Uint8Array(digest)); +} + +export async function sha256Text(value: string): Promise { + return sha256Bytes(new TextEncoder().encode(value)); +} + +export async function createSourceAnchor( + input: Omit & { quote: string } +): Promise { const quote = normalizeQuote(input.quote); if (!quote) throw new Error("Cannot anchor an empty selection."); + if (!/^[a-f0-9]{64}$/.test(input.documentSha256)) throw new Error("Document SHA-256 is invalid."); if (input.page < 1) throw new Error("Page numbers are one-based."); if (input.startItem < 0 || input.endItem < input.startItem) throw new Error("Invalid text-item range."); + if (input.startOffset < 0 || input.endOffset < 0) throw new Error("Selection offsets cannot be negative."); return { ...input, quote, - version: 1, + quoteSha256: await sha256Text(quote), + version: 2, createdAt: new Date().toISOString() }; } -export function anchorMatchesDocument(anchor: SourceAnchor, fingerprint: string): boolean { - return anchor.documentFingerprint === fingerprint; +export function anchorMatchesDocument(anchor: SourceAnchor, sha256: string): boolean { + return anchor.documentSha256 === sha256; } -export function fingerprintBytes(bytes: Uint8Array): string { - // Fast, deterministic non-cryptographic fingerprint for UI anchoring only. - // A cryptographic document hash will replace this at the sidecar/provenance milestone. - let hash = 2166136261; - const step = Math.max(1, Math.floor(bytes.length / 8192)); - for (let i = 0; i < bytes.length; i += step) { - hash ^= bytes[i]; - hash = Math.imul(hash, 16777619); +export function resolveAnchorRange(anchor: SourceAnchor, itemTexts: string[]): ResolvedAnchorRange | null { + const expected = normalizeQuote(anchor.quote); + if (!expected) return null; + + const storedSlice = normalizeQuote(itemTexts.slice(anchor.startItem, anchor.endItem + 1).join(" ")); + if (storedSlice === expected) { + return { startItem: anchor.startItem, endItem: anchor.endItem }; } - hash ^= bytes.length; - return `fnv1a-${(hash >>> 0).toString(16).padStart(8, "0")}-${bytes.length}`; + + // Fallback for a renderer/text-item split that changes while the PDF content stays identical. + // We search the page's text items for the same normalized source quote. + for (let startItem = 0; startItem < itemTexts.length; startItem += 1) { + let candidate = ""; + for (let endItem = startItem; endItem < itemTexts.length; endItem += 1) { + candidate = normalizeQuote(`${candidate} ${itemTexts[endItem]}`); + if (candidate === expected) return { startItem, endItem }; + if (candidate.length > expected.length + 16) break; + } + } + + return null; } diff --git a/src/styles.css b/src/styles.css index d8ff3b0..66df626 100644 --- a/src/styles.css +++ b/src/styles.css @@ -1,64 +1,108 @@ :root { font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; - color: #172033; - background: #eef1f5; font-synthesis: none; text-rendering: optimizeLegibility; + --text: #172033; + --muted: #667085; + --muted-strong: #475467; + --app-bg: #eef1f5; + --surface: #ffffff; + --surface-soft: #f9fafb; + --surface-muted: #f2f4f7; + --surface-output: #f8fafc; + --stage: #dfe4eb; + --border: #d9dee7; + --border-soft: #e4e7ec; + --control-border: #d0d5dd; + --control-bg: #ffffff; + --primary: #2457d6; + --primary-active: #1d4ed8; + --privacy-fg: #176b45; + --privacy-bg: #eaf8f1; + --privacy-dot: #19a463; + --shadow: rgb(16 24 40 / 18%); + --highlight: rgb(255 199 61 / 38%); +} + +html[data-theme="dark"] { + color-scheme: dark; + --text: #e6eaf0; + --muted: #9aa6b6; + --muted-strong: #b6c0cd; + --app-bg: #11161d; + --surface: #171d25; + --surface-soft: #1c232d; + --surface-muted: #222b36; + --surface-output: #1b232d; + --stage: #0d1218; + --border: #2e3946; + --border-soft: #34414f; + --control-border: #3b4857; + --control-bg: #202832; + --primary: #5d83f2; + --primary-active: #8aa6ff; + --privacy-fg: #79d8aa; + --privacy-bg: #173126; + --privacy-dot: #56c98e; + --shadow: rgb(0 0 0 / 42%); + --highlight: rgb(255 199 61 / 45%); } * { box-sizing: border-box; } -body { margin: 0; min-width: 980px; min-height: 100vh; background: #eef1f5; } +body { margin: 0; min-width: 980px; min-height: 100vh; background: var(--app-bg); color: var(--text); } button, select, textarea { font: inherit; } -button { cursor: pointer; } +button { cursor: pointer; color: inherit; } button:disabled { cursor: default; opacity: .45; } -.app-shell { min-height: 100vh; display: flex; flex-direction: column; } +.app-shell { min-height: 100vh; display: flex; flex-direction: column; background: var(--app-bg); color: var(--text); } .topbar { height: 64px; display: flex; align-items: center; gap: 14px; padding: 0 18px; - background: #ffffff; border-bottom: 1px solid #d9dee7; position: sticky; top: 0; z-index: 20; + background: var(--surface); border-bottom: 1px solid var(--border); position: sticky; top: 0; z-index: 20; } .brand { display: flex; align-items: baseline; gap: 8px; margin-right: auto; } -.version { font-size: 12px; color: #667085; } -.privacy-badge { display: flex; align-items: center; gap: 7px; font-size: 12px; font-weight: 700; color: #176b45; background: #eaf8f1; padding: 8px 10px; border-radius: 999px; } -.privacy-dot { width: 8px; height: 8px; border-radius: 50%; background: #19a463; } -.primary { border: 0; border-radius: 9px; background: #2457d6; color: white; padding: 9px 14px; font-weight: 700; } +.version { font-size: 12px; color: var(--muted); } +.privacy-badge { display: flex; align-items: center; gap: 7px; font-size: 12px; font-weight: 700; color: var(--privacy-fg); background: var(--privacy-bg); padding: 8px 10px; border-radius: 999px; } +.privacy-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--privacy-dot); } +.primary { border: 0; border-radius: 9px; background: var(--primary); color: white; padding: 9px 14px; font-weight: 700; } .primary.large { padding: 12px 18px; } +.theme-toggle { border: 1px solid var(--control-border); border-radius: 9px; background: var(--control-bg); padding: 9px 12px; font-weight: 650; } .workspace { display: grid; grid-template-columns: minmax(0, 1fr) 380px; min-height: calc(100vh - 64px); } .reader-column { min-width: 0; display: flex; flex-direction: column; } -.reader-toolbar { height: 52px; display: flex; align-items: center; gap: 16px; padding: 0 16px; border-bottom: 1px solid #d9dee7; background: #f9fafb; } +.reader-toolbar { height: 52px; display: flex; align-items: center; gap: 16px; padding: 0 16px; border-bottom: 1px solid var(--border); background: var(--surface-soft); } .file-label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 13px; font-weight: 600; margin-right: auto; } -.toolbar-actions { display: flex; align-items: center; gap: 7px; font-size: 12px; color: #475467; } -.toolbar-actions button { min-width: 32px; height: 30px; border: 1px solid #d0d5dd; background: white; border-radius: 7px; } -.document-stage { flex: 1; min-height: 0; overflow: auto; padding: 28px; display: flex; align-items: flex-start; justify-content: center; background: #dfe4eb; } -.empty-state { margin: auto; max-width: 620px; text-align: center; background: white; border: 1px solid #d9dee7; border-radius: 16px; padding: 42px; box-shadow: 0 12px 35px rgb(16 24 40 / 8%); } +.toolbar-actions { display: flex; align-items: center; gap: 7px; font-size: 12px; color: var(--muted-strong); } +.toolbar-actions button { min-width: 32px; height: 30px; border: 1px solid var(--control-border); background: var(--control-bg); border-radius: 7px; } +.document-stage { flex: 1; min-height: 0; overflow: auto; padding: 28px; display: flex; align-items: flex-start; justify-content: center; background: var(--stage); } +.empty-state { margin: auto; max-width: 620px; text-align: center; background: var(--surface); border: 1px solid var(--border); border-radius: 16px; padding: 42px; box-shadow: 0 12px 35px var(--shadow); } .empty-state h1 { margin-top: 0; font-size: 28px; } -.empty-state p { color: #667085; line-height: 1.6; } -.loading { margin: auto; color: #667085; } +.empty-state p { color: var(--muted); line-height: 1.6; } +.loading { margin: auto; color: var(--muted); } -.pdf-page { position: relative; flex: 0 0 auto; background: white; box-shadow: 0 8px 28px rgb(16 24 40 / 18%); } +.pdf-page { position: relative; flex: 0 0 auto; background: white; box-shadow: 0 8px 28px var(--shadow); } .pdf-canvas { position: absolute; inset: 0; display: block; } .text-layer { position: absolute; inset: 0; overflow: hidden; line-height: 1; opacity: 1; user-select: text; } .text-item { position: absolute; white-space: pre; color: transparent; cursor: text; user-select: text; } .text-item::selection { background: rgb(75 126 255 / 38%); color: transparent; } -.text-item.anchored { background: rgb(255 199 61 / 38%); border-radius: 2px; } +.text-item.anchored { background: var(--highlight); border-radius: 2px; } -.understanding-panel { border-left: 1px solid #d9dee7; background: #ffffff; padding: 16px; overflow: auto; } -.panel-tabs { display: grid; grid-template-columns: repeat(3, 1fr); background: #f2f4f7; border-radius: 10px; padding: 4px; gap: 4px; } -.panel-tabs button { border: 0; padding: 8px 6px; border-radius: 7px; background: transparent; color: #475467; font-weight: 700; } -.panel-tabs button.active { background: white; color: #1d4ed8; box-shadow: 0 1px 3px rgb(16 24 40 / 12%); } -.source-card, .controls-card, .output-card { margin-top: 14px; border: 1px solid #e4e7ec; border-radius: 12px; padding: 14px; } -.eyebrow { font-size: 11px; letter-spacing: .08em; font-weight: 800; color: #667085; } -.source-meta { font-size: 12px; color: #667085; margin-top: 7px; } +.understanding-panel { border-left: 1px solid var(--border); background: var(--surface); padding: 16px; overflow: auto; } +.panel-tabs { display: grid; grid-template-columns: repeat(3, 1fr); background: var(--surface-muted); border-radius: 10px; padding: 4px; gap: 4px; } +.panel-tabs button { border: 0; padding: 8px 6px; border-radius: 7px; background: transparent; color: var(--muted-strong); font-weight: 700; } +.panel-tabs button.active { background: var(--surface); color: var(--primary-active); box-shadow: 0 1px 3px var(--shadow); } +.source-card, .controls-card, .output-card { margin-top: 14px; border: 1px solid var(--border-soft); border-radius: 12px; padding: 14px; } +.eyebrow { font-size: 11px; letter-spacing: .08em; font-weight: 800; color: var(--muted); } +.source-meta { font-size: 12px; color: var(--muted); margin-top: 7px; } blockquote { margin: 10px 0; padding-left: 12px; border-left: 3px solid #98a2b3; line-height: 1.55; } -.link-button { border: 0; background: transparent; color: #2457d6; padding: 0; font-weight: 700; } +.provenance-meta { display: grid; gap: 3px; margin: 0 0 10px; color: var(--muted); font-size: 11px; word-break: break-all; } +.link-button { border: 0; background: transparent; color: var(--primary); padding: 0; font-weight: 700; } .controls-card { display: grid; gap: 12px; } -.controls-card label { display: grid; gap: 6px; font-size: 12px; font-weight: 700; color: #475467; } -select, textarea { width: 100%; border: 1px solid #d0d5dd; border-radius: 8px; background: white; color: #172033; padding: 9px; } +.controls-card label { display: grid; gap: 6px; font-size: 12px; font-weight: 700; color: var(--muted-strong); } +select, textarea { width: 100%; border: 1px solid var(--control-border); border-radius: 8px; background: var(--control-bg); color: var(--text); padding: 9px; } textarea { min-height: 86px; resize: vertical; } -.output-card { background: #f8fafc; line-height: 1.55; } +.output-card { background: var(--surface-output); line-height: 1.55; } .output-card p { margin-bottom: 0; } -.status-line { margin-top: 14px; font-size: 12px; color: #667085; line-height: 1.4; } +.status-line { margin-top: 14px; font-size: 12px; color: var(--muted); line-height: 1.4; } @media (max-width: 1120px) { .workspace { grid-template-columns: minmax(0, 1fr) 330px; }