diff --git a/docs/agents/branding.md b/docs/agents/branding.md index 1c83b852..4518e564 100644 --- a/docs/agents/branding.md +++ b/docs/agents/branding.md @@ -68,8 +68,8 @@ Treat this amber as a **difference-revealing accent** and a small part of the De |----------|---------|-------| | `--dc-popover-width` | `480px` | Popover container width | | `--dc-keyhole-strip-height` | `120px` | Evidence keyhole strip height | -| `--dc-document-canvas-bg-light` | `rgb(243 244 246)` | Light-mode proof image background | -| `--dc-document-canvas-bg-dark` | `rgb(31 41 55)` | Dark-mode proof image background | +| `--dc-document-canvas-bg-light` | `rgb(244 244 245)` | Light-mode proof image background | +| `--dc-document-canvas-bg-dark` | `rgb(39 39 42)` | Dark-mode proof image background | | `--dc-guard-max-width` | `calc(100dvw - 2rem)` | Viewport-constrained max width | ### Surface Alignment Considerations @@ -118,7 +118,7 @@ For the SDK, these are **reference surfaces, not defaults**: **React component (declarative):** ```tsx -import { DeepCitationTheme } from "deepcitation"; +import { DeepCitationTheme } from "deepcitation/react"; void; -} -``` - -Default UI behavior: - -- `original_plus_url_pdf`: show `originalDownload` when present; for URL inputs (no `originalDownload`), show `convertedDownload` -- `original_plus_all_pdf`: show `originalDownload` when present, else show `convertedDownload` -- `original_only`: show `originalDownload` only, never `convertedDownload` diff --git a/examples/langchain-rag-chat/vercel.json b/examples/langchain-rag-chat/vercel.json new file mode 100644 index 00000000..092cc167 --- /dev/null +++ b/examples/langchain-rag-chat/vercel.json @@ -0,0 +1,9 @@ +{ + "$schema": "https://openapi.vercel.sh/vercel.json", + "framework": "nextjs", + "ignoreCommand": "git diff --quiet HEAD^ HEAD -- .", + "github": { + "enabled": true, + "silent": true + } +} diff --git a/scripts/docs-audit-inventory.mjs b/scripts/docs-audit-inventory.mjs new file mode 100644 index 00000000..15ac2655 --- /dev/null +++ b/scripts/docs-audit-inventory.mjs @@ -0,0 +1,547 @@ +#!/usr/bin/env node +/** + * Docs Audit Inventory — Deterministic checks for documentation drift + * + * Runs 6 checks against the docs/ directory and outputs JSON to stdout. + * Designed to be consumed by the `/docs-audit` Claude command for semantic evaluation. + * + * Usage: + * node scripts/docs-audit-inventory.mjs + * + * Checks: + * 1. Broken internal links (Jekyll `{{ site.baseurl }}/slug/` patterns) + * 2. Removed dependency mentions (packages once used, now gone) + * 3. Referenced source file existence (`src/...` paths in docs) + * 4. Agent doc staleness (commits to watched source files since doc last modified) + * 5. Code block import validation (verify exported symbols in fenced code blocks) + * 6. Interface field drift (doc vs source interface shapes) + */ + +import { + readdirSync, + readFileSync, + existsSync, +} from "node:fs"; +import { execFileSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; +import { collectDocsFiles, parseYamlFrontmatter, loadDocsContents } from "./lib/docs-utils.mjs"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); +const ROOT = join(__dirname, ".."); + +// ─── Known Removed Dependencies ───────────────────────────────────────────── +// Packages that were once in package.json but have been removed. +// Mentions of these in docs are likely stale. +const KNOWN_REMOVED_DEPS = [ + "@radix-ui/react-popover", + "@radix-ui/react-portal", + "@radix-ui/react-slot", + "@radix-ui/react-presence", + "@floating-ui/react-dom", +]; + +// ─── Helpers ──────────────────────────────────────────────────────────────── + +function getJekyllSlugs(docContents) { + const slugs = new Set(); + for (const [relPath, content] of docContents) { + if (relPath.startsWith("agents/")) continue; // agent docs aren't public pages + const { data: fm } = parseYamlFrontmatter(content); + if (!fm.layout) continue; // not a Jekyll page + // Derive slug from permalink or filename + if (fm.permalink) { + slugs.add(fm.permalink.replace(/^\/|\/$/g, "")); + } else { + const slug = relPath.replace(/\.md$/, "").replace(/\/index$/, ""); + slugs.add(slug); + } + } + // index page + slugs.add(""); + return slugs; +} + +function getLastModifiedDate(filePath) { + try { + const output = execFileSync( + "git", ["log", "-1", "--format=%aI", "--", filePath], + { cwd: ROOT, encoding: "utf8" }, + ).trim(); + return output || null; + } catch { + return null; + } +} + +/** Module content cache for getExportsFromModule — avoids re-reading the same file. */ +const moduleContentCache = new Map(); + +function getExportsFromModule(modulePath) { + if (moduleContentCache.has(modulePath)) { + return moduleContentCache.get(modulePath); + } + + let content; + try { + content = readFileSync(modulePath, "utf8"); + } catch { + moduleContentCache.set(modulePath, null); + return null; + } + + const exports = new Set(); + + // Match: export { Foo, Bar } + for (const m of content.matchAll(/export\s*\{([^}]+)\}/g)) { + for (const name of m[1].split(",")) { + const cleaned = name.replace(/\s+as\s+\w+/, "").trim(); + if (cleaned) exports.add(cleaned); + } + } + // Match: export const/function/class/type/interface Foo + for (const m of content.matchAll(/export\s+(?:const|let|var|function|class|type|interface|enum)\s+(\w+)/g)) { + exports.add(m[1]); + } + // Match: export default + if (content.match(/export\s+default\s/)) { + exports.add("default"); + } + + const result = { exports, content }; + moduleContentCache.set(modulePath, result); + return result; +} + +// ─── Check 1: Broken Internal Links ──────────────────────────────────────── + +function checkBrokenLinks(docContents) { + const findings = []; + const validSlugs = getJekyllSlugs(docContents); + + // Match {{ site.baseurl }}/slug/ patterns + const linkPattern = /\{\{\s*site\.baseurl\s*\}\}\/([a-z0-9_-]+(?:\/[a-z0-9_-]+)*)\//gi; + + for (const [relPath, content] of docContents) { + const lines = content.split("\n"); + + for (let i = 0; i < lines.length; i++) { + for (const match of lines[i].matchAll(linkPattern)) { + const slug = match[1]; + if (!validSlugs.has(slug)) { + findings.push({ + file: `docs/${relPath}`, + line: i + 1, + slug, + text: lines[i].trim(), + }); + } + } + } + } + + return findings; +} + +// ─── Check 2: Removed Dependency Mentions ────────────────────────────────── + +function checkRemovedDeps(docContents) { + const findings = []; + + // Build search terms from removed deps (package name + short name) + const searchTerms = []; + for (const dep of KNOWN_REMOVED_DEPS) { + searchTerms.push({ termLower: dep.toLowerCase(), term: dep, package: dep }); + // Also search for the unscoped short name (e.g. "radix" from "@radix-ui/react-popover") + const shortMatch = dep.match(/@([^/]+)\//); + if (shortMatch) { + const shortName = shortMatch[1].replace(/-ui$/, ""); // "radix-ui" → "radix" + searchTerms.push({ termLower: shortName.toLowerCase(), term: shortName, package: dep }); + } + } + + // Deduplicate short names + const seen = new Set(); + const uniqueTerms = searchTerms.filter((t) => { + if (seen.has(t.term)) return false; + seen.add(t.term); + return true; + }); + + for (const [relPath, content] of docContents) { + const lines = content.split("\n"); + + for (let i = 0; i < lines.length; i++) { + const lower = lines[i].toLowerCase(); + for (const { termLower, term, package: pkg } of uniqueTerms) { + if (lower.includes(termLower)) { + findings.push({ + file: `docs/${relPath}`, + line: i + 1, + term, + package: pkg, + text: lines[i].trim(), + }); + } + } + } + } + + return findings; +} + +// ─── Check 3: Referenced Source File Existence ───────────────────────────── + +function checkSourceFileRefs(docContents) { + const findings = []; + + // Match src/... paths (backtick-wrapped or bare) - common patterns in docs + const srcPattern = /`(src\/[a-zA-Z0-9_./-]+(?:\.[a-z]+)?)`/g; + + for (const [relPath, content] of docContents) { + const lines = content.split("\n"); + + for (let i = 0; i < lines.length; i++) { + for (const match of lines[i].matchAll(srcPattern)) { + const srcPath = match[1]; + // Only check paths with file extensions (skip directory references) + if (!srcPath.match(/\.\w+$/)) continue; + if (!existsSync(join(ROOT, srcPath))) { + findings.push({ + file: `docs/${relPath}`, + line: i + 1, + referencedPath: srcPath, + text: lines[i].trim(), + }); + } + } + } + } + + return findings; +} + +// ─── Check 4: Agent Doc Staleness ────────────────────────────────────────── + +function checkAgentDocStaleness() { + const findings = []; + const agentsDir = join(ROOT, "docs", "agents"); + if (!existsSync(agentsDir)) return findings; + + const agentFiles = readdirSync(agentsDir) + .filter((f) => f.endsWith(".md") && !f.startsWith("_")) + .sort(); + + for (const file of agentFiles) { + const fullPath = join(agentsDir, file); + const content = readFileSync(fullPath, "utf8"); + + // Extract src/ paths referenced in the doc + const srcRefs = new Set(); + for (const match of content.matchAll(/`(src\/[a-zA-Z0-9_./-]+(?:\.\w+)?)`/g)) { + srcRefs.add(match[1]); + } + + if (srcRefs.size === 0) continue; + + // Get the doc's last modification date + const lastModified = getLastModifiedDate(`docs/agents/${file}`); + if (!lastModified) continue; + + // Count commits to referenced source files since doc was last modified + const srcPaths = [...srcRefs].filter((p) => existsSync(join(ROOT, p))); + if (srcPaths.length === 0) continue; + + try { + const output = execFileSync( + "git", ["log", "--oneline", `--since=${lastModified}`, "--", ...srcPaths], + { cwd: ROOT, encoding: "utf8" }, + ).trim(); + const commits = output ? output.split("\n").length : 0; + + if (commits > 0) { + findings.push({ + file: `docs/agents/${file}`, + lastModified, + referencedSrcFiles: srcPaths, + commitsSinceDocUpdate: commits, + }); + } + } catch { + // skip on git error + } + } + + return findings; +} + +// ─── Check 5: Code Block Import Validation ───────────────────────────────── + +function checkCodeBlockImports(docContents) { + const findings = []; + + // Match fenced code blocks + const codeBlockPattern = /```(?:tsx?|jsx?|javascript|typescript)\n([\s\S]*?)```/g; + // Match import statements + const importPattern = /import\s+\{([^}]+)\}\s+from\s+["']([^"']+)["']/g; + + for (const [relPath, content] of docContents) { + for (const blockMatch of content.matchAll(codeBlockPattern)) { + const codeBlock = blockMatch[1]; + const blockStart = content.substring(0, blockMatch.index).split("\n").length; + + for (const impMatch of codeBlock.matchAll(importPattern)) { + const symbols = impMatch[1].split(",").map((s) => s.trim()).filter(Boolean); + const modulePath = impMatch[2]; + + // Only check internal imports (deepcitation or relative) + if (!modulePath.startsWith("deepcitation") && !modulePath.startsWith(".")) continue; + + // Resolve module to a file + let resolvedPath = null; + if (modulePath.startsWith("deepcitation")) { + // Map package subpath to source + const subpath = modulePath.replace(/^deepcitation\/?/, ""); + if (!subpath || subpath === "") { + resolvedPath = join(ROOT, "src", "index.ts"); + } else { + resolvedPath = join(ROOT, "src", subpath, "index.ts"); + if (!existsSync(resolvedPath)) { + resolvedPath = join(ROOT, "src", `${subpath}.ts`); + } + if (!existsSync(resolvedPath)) { + resolvedPath = join(ROOT, "src", `${subpath}.tsx`); + } + } + } + + if (!resolvedPath || !existsSync(resolvedPath)) continue; + + const moduleResult = getExportsFromModule(resolvedPath); + if (!moduleResult) continue; + + for (const sym of symbols) { + // Handle `type X` imports + const cleanSym = sym.replace(/^type\s+/, ""); + if (!moduleResult.exports.has(cleanSym)) { + // Check re-exports by searching for the symbol in the file content + if (!moduleResult.content.includes(cleanSym)) { + const lineInBlock = codeBlock.substring(0, impMatch.index).split("\n").length; + findings.push({ + file: `docs/${relPath}`, + line: blockStart + lineInBlock - 1, + symbol: cleanSym, + module: modulePath, + resolvedFile: resolvedPath.replace(ROOT + "/", ""), + }); + } + } + } + } + } + } + + return findings; +} + +// ─── Check 6: Interface Field Drift ───────────────────────────────────────── +// Compares interface/type declarations in doc code blocks against the actual +// source TypeScript files. Catches phantom fields (exist in docs, removed from +// source) and missing fields (added to source, missing from docs). + +/** Extract field names from a TypeScript interface body string (top-level only). */ +function extractInterfaceFields(body) { + const fields = new Set(); + let depth = 0; + for (const line of body.split("\n")) { + const trimmed = line.trim(); + // Skip comments, empty lines + if (!trimmed || trimmed.startsWith("//") || trimmed.startsWith("/*") || trimmed.startsWith("*")) continue; + // At top level (depth 0), try to extract a field name BEFORE updating depth. + // This handles `fieldName?: { nested: type }` where braces open on the same line. + if (depth === 0) { + const fieldMatch = trimmed.match(/^(\w+)\??\s*:/); + if (fieldMatch) { + fields.add(fieldMatch[1]); + } + } + // Update brace depth for subsequent lines + for (const ch of trimmed) { + if (ch === "{") depth++; + if (ch === "}") depth--; + } + } + return fields; +} + +/** Parse all `interface Foo { ... }` blocks from a string. */ +function parseInterfaces(content) { + const interfaces = new Map(); + // Match interface declarations (handles extends) + const pattern = /interface\s+(\w+)(?:\s+extends\s+\w+)?\s*\{/g; + let match; + while ((match = pattern.exec(content)) !== null) { + const name = match[1]; + const startBrace = match.index + match[0].length; + // Find matching closing brace (handle nesting) + let depth = 1; + let i = startBrace; + while (i < content.length && depth > 0) { + if (content[i] === "{") depth++; + if (content[i] === "}") depth--; + i++; + } + const body = content.substring(startBrace, i - 1); + interfaces.set(name, extractInterfaceFields(body)); + } + return interfaces; +} + +/** + * Map of interface names to their canonical source file paths. + * Only interfaces we want to track for drift are listed here. + */ +const TRACKED_INTERFACES = { + Verification: "src/types/verification.ts", + SearchAttempt: "src/types/search.ts", + EvidenceImage: "src/types/verification.ts", + PageImage: "src/types/verification.ts", + DocumentVerificationResult: "src/types/verification.ts", + UrlVerificationResult: "src/types/verification.ts", + DownloadLink: "src/types/verification.ts", + FileDownload: "src/types/verification.ts", + DocumentCitation: "src/types/citation.ts", + UrlCitation: "src/types/citation.ts", + AudioVideoCitation: "src/types/citation.ts", + CitationBase: "src/types/citation.ts", + VerifyCitationRequest: "src/types/citation.ts", + VerifyCitationResponse: "src/types/citation.ts", + CitationStatus: "src/types/citation.ts", +}; + +function checkInterfaceFieldDrift(docContents) { + const findings = []; + + // Cache source interfaces per file + const sourceCache = new Map(); + function getSourceInterfaces(srcRelPath) { + if (sourceCache.has(srcRelPath)) return sourceCache.get(srcRelPath); + const fullPath = join(ROOT, srcRelPath); + let content; + try { + content = readFileSync(fullPath, "utf8"); + } catch { + sourceCache.set(srcRelPath, null); + return null; + } + const parsed = parseInterfaces(content); + sourceCache.set(srcRelPath, parsed); + return parsed; + } + + const codeBlockPattern = /```(?:tsx?|jsx?|javascript|typescript)\n([\s\S]*?)```/g; + + for (const [relPath, content] of docContents) { + for (const blockMatch of content.matchAll(codeBlockPattern)) { + const codeBlock = blockMatch[1]; + const blockStartLine = content.substring(0, blockMatch.index).split("\n").length; + + // Parse interfaces from this code block + const docInterfaces = parseInterfaces(codeBlock); + + for (const [ifaceName, docFields] of docInterfaces) { + const srcFile = TRACKED_INTERFACES[ifaceName]; + if (!srcFile) continue; // not tracked + + const sourceInterfaces = getSourceInterfaces(srcFile); + if (!sourceInterfaces) { + findings.push({ + file: `docs/${relPath}`, + line: blockStartLine, + interface: ifaceName, + sourceFile: srcFile, + issue: "source_file_missing", + details: `Source file ${srcFile} not found`, + }); + continue; + } + + const sourceFields = sourceInterfaces.get(ifaceName); + if (!sourceFields) { + findings.push({ + file: `docs/${relPath}`, + line: blockStartLine, + interface: ifaceName, + sourceFile: srcFile, + issue: "interface_not_found_in_source", + details: `Interface ${ifaceName} not found in ${srcFile}`, + }); + continue; + } + + // Fields in docs but not in source (phantom fields) + const phantomFields = [...docFields].filter((f) => !sourceFields.has(f)); + // Fields in source but not in docs (missing coverage) + const missingFields = [...sourceFields].filter((f) => !docFields.has(f)); + + if (phantomFields.length > 0) { + findings.push({ + file: `docs/${relPath}`, + line: blockStartLine, + interface: ifaceName, + sourceFile: srcFile, + issue: "phantom_fields", + fields: phantomFields, + details: `Doc has fields not in source: ${phantomFields.join(", ")}`, + }); + } + + if (missingFields.length > 0) { + findings.push({ + file: `docs/${relPath}`, + line: blockStartLine, + interface: ifaceName, + sourceFile: srcFile, + issue: "missing_fields", + fields: missingFields, + details: `Source has fields not in doc: ${missingFields.join(", ")}`, + }); + } + } + } + } + + return findings; +} + +// ─── Main ─────────────────────────────────────────────────────────────────── + +function main() { + // Single-pass: collect and read all docs files once + const docsDir = join(ROOT, "docs"); + const mdFiles = collectDocsFiles(docsDir); + const docContents = loadDocsContents(docsDir, mdFiles); + + const results = { + generated_at: new Date().toISOString(), + checks: { + broken_links: checkBrokenLinks(docContents), + removed_dep_mentions: checkRemovedDeps(docContents), + missing_source_files: checkSourceFileRefs(docContents), + agent_doc_staleness: checkAgentDocStaleness(), + code_block_imports: checkCodeBlockImports(docContents), + interface_field_drift: checkInterfaceFieldDrift(docContents), + }, + }; + + // Summary counts for quick overview + results.summary = {}; + for (const [key, arr] of Object.entries(results.checks)) { + results.summary[key] = arr.length; + } + + console.log(JSON.stringify(results, null, 2)); +} + +main(); diff --git a/scripts/lib/docs-utils.mjs b/scripts/lib/docs-utils.mjs new file mode 100644 index 00000000..0add6947 --- /dev/null +++ b/scripts/lib/docs-utils.mjs @@ -0,0 +1,74 @@ +/** + * Shared helpers for scripts that process docs/ markdown files. + * Used by both refresh-agent-index.mjs and docs-audit-inventory.mjs. + */ + +import { readdirSync, readFileSync, existsSync } from "node:fs"; +import { join } from "node:path"; + +/** + * Recursively collect .md files under `dir`. + * @param {string} dir — absolute path to walk + * @param {{ exclude?: string[] }} opts — directory names to skip (e.g. ["agents"]) + * @param {string} relBase — internal recursion state + * @returns {string[]} relative paths from `dir` + */ +export function collectDocsFiles(dir, opts = {}, relBase = "") { + const exclude = opts.exclude ?? []; + const files = []; + if (!existsSync(dir)) return files; + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const relPath = relBase ? `${relBase}/${entry.name}` : entry.name; + if (entry.isDirectory()) { + if (entry.name.startsWith("_") || entry.name === "node_modules") continue; + if (exclude.includes(entry.name)) continue; + files.push(...collectDocsFiles(join(dir, entry.name), opts, relPath)); + } else if (entry.name.endsWith(".md") && !entry.name.startsWith("_")) { + files.push(relPath); + } + } + return files; +} + +/** + * Parse YAML-ish frontmatter from a markdown file. + * Returns `{ data, watchPaths }` where data is a flat key→value map + * and watchPaths is an array of `watch_paths:` entries (if any). + */ +export function parseYamlFrontmatter(content) { + const match = content.match(/^---\n([\s\S]*?)\n---/); + if (!match) return { data: {}, watchPaths: [] }; + const text = match[1]; + const data = {}; + let currentKey = null; + const watchPaths = []; + for (const line of text.split("\n")) { + const kvMatch = line.match(/^(\w[\w_]*)\s*:\s*(.+)$/); + if (kvMatch) { + currentKey = kvMatch[1]; + data[currentKey] = kvMatch[2].replace(/^["']|["']$/g, "").trim(); + continue; + } + const keyOnly = line.match(/^(\w[\w_]*)\s*:\s*$/); + if (keyOnly) { + currentKey = keyOnly[1]; + continue; + } + const arrMatch = line.match(/^\s+-\s+"?([^"]*)"?\s*$/); + if (arrMatch && currentKey === "watch_paths") { + watchPaths.push(arrMatch[1]); + } + } + return { data, watchPaths }; +} + +/** + * Load all docs files into a Map for single-pass I/O. + */ +export function loadDocsContents(docsDir, mdFiles) { + const contents = new Map(); + for (const relPath of mdFiles) { + contents.set(relPath, readFileSync(join(docsDir, relPath), "utf8")); + } + return contents; +} diff --git a/scripts/refresh-agent-index.mjs b/scripts/refresh-agent-index.mjs index 2c54445c..60b232a5 100644 --- a/scripts/refresh-agent-index.mjs +++ b/scripts/refresh-agent-index.mjs @@ -22,6 +22,7 @@ import { import { execSync, execFileSync } from "node:child_process"; import { fileURLToPath } from "node:url"; import { dirname, join } from "node:path"; +import { collectDocsFiles, parseYamlFrontmatter } from "./lib/docs-utils.mjs"; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); @@ -136,32 +137,6 @@ function getSubdirs(dirPath) { } } -function parseYamlFrontmatter(content) { - const match = content.match(/^---\n([\s\S]*?)\n---/); - if (!match) return { data: {}, watchPaths: [] }; - const text = match[1]; - const data = {}; - let currentKey = null; - const watchPaths = []; - for (const line of text.split("\n")) { - const kvMatch = line.match(/^(\w[\w_]*)\s*:\s*(.+)$/); - if (kvMatch) { - currentKey = kvMatch[1]; - data[currentKey] = kvMatch[2].replace(/^["']|["']$/g, "").trim(); - continue; - } - const keyOnly = line.match(/^(\w[\w_]*)\s*:\s*$/); - if (keyOnly) { - currentKey = keyOnly[1]; - continue; - } - const arrMatch = line.match(/^\s+-\s+"?([^"]*)"?\s*$/); - if (arrMatch && currentKey === "watch_paths") { - watchPaths.push(arrMatch[1]); - } - } - return { data, watchPaths }; -} // ─── Section 1: Generate repo-map.md ──────────────────────────────────────── function generateRepoMap(gitInfo) { @@ -383,24 +358,7 @@ function validatePathRouter(gitInfo) { function inventoryPublicDocs(gitInfo) { const docsDir = join(ROOT, "docs"); - // Collect all .md files under docs/ except docs/agents/ - function collectMdFiles(dir, relBase = "") { - const files = []; - if (!existsSync(dir)) return files; - for (const entry of readdirSync(dir, { withFileTypes: true })) { - const relPath = relBase ? `${relBase}/${entry.name}` : entry.name; - if (entry.isDirectory()) { - if (entry.name === "agents") continue; // skip agent docs - if (entry.name.startsWith("_")) continue; // skip Jekyll internals - files.push(...collectMdFiles(join(dir, entry.name), relPath)); - } else if (entry.name.endsWith(".md") && !entry.name.startsWith("_")) { - files.push(relPath); - } - } - return files; - } - - const mdFiles = collectMdFiles(docsDir).sort(); + const mdFiles = collectDocsFiles(docsDir, { exclude: ["agents"] }).sort(); const pages = []; for (const relPath of mdFiles) { diff --git a/src/client/types.ts b/src/client/types.ts index 0eff7bfd..81ccd650 100644 --- a/src/client/types.ts +++ b/src/client/types.ts @@ -449,6 +449,8 @@ export interface AttachmentResponse { urlSource?: UrlSource; /** Expiration date */ expiresAt?: (string & {}) | "never"; + /** Developer-provided end-user identifier for per-customer usage attribution */ + endUserId?: string; /** Original file as received (PDF, DOCX, MP4, …). Absent for URL inputs. */ originalDownload?: FileDownload; /** Converted artifact: PDF rendition for docs/URLs, transcript for audio/video. */ diff --git a/src/react/constants.ts b/src/react/constants.ts index b9b07205..6e96ade3 100644 --- a/src/react/constants.ts +++ b/src/react/constants.ts @@ -137,7 +137,7 @@ export const DOCUMENT_CANVAS_BG_LIGHT_VAR = "--dc-document-canvas-bg-light"; export const DOCUMENT_CANVAS_BG_DARK_VAR = "--dc-document-canvas-bg-dark"; /** Neutral canvas behind page images so white documents stay visually bounded. */ export const DOCUMENT_CANVAS_BG_CLASSES = - "bg-[var(--dc-document-canvas-bg-light,rgb(243_244_246))] dark:bg-[var(--dc-document-canvas-bg-dark,rgb(31_41_55))]"; + "bg-[var(--dc-document-canvas-bg-light,rgb(244_244_245))] dark:bg-[var(--dc-document-canvas-bg-dark,rgb(39_39_42))]"; /** Subtle outline around document images to preserve edge contrast on light canvases. */ export const DOCUMENT_IMAGE_EDGE_CLASSES = "ring-1 ring-black/10 dark:ring-white/15"; diff --git a/src/react/hooks/useViewportBoundaryGuard.ts b/src/react/hooks/useViewportBoundaryGuard.ts index 8acdbd30..b65ddaaf 100644 --- a/src/react/hooks/useViewportBoundaryGuard.ts +++ b/src/react/hooks/useViewportBoundaryGuard.ts @@ -135,14 +135,12 @@ export function useViewportBoundaryGuard( function clamp(el: HTMLElement): void { const vw = getVisibleViewportWidth(); - const vh = document.documentElement.clientHeight; applyGuardMaxWidth(el, vw); el.style.translate = ""; const rect = el.getBoundingClientRect(); let dx = 0; - let dy = 0; if (rect.left < VIEWPORT_MARGIN_PX) { dx = VIEWPORT_MARGIN_PX - rect.left; @@ -150,13 +148,11 @@ function clamp(el: HTMLElement): void { dx = vw - VIEWPORT_MARGIN_PX - rect.right; } - if (rect.top < VIEWPORT_MARGIN_PX) { - dy = VIEWPORT_MARGIN_PX - rect.top; - } else if (rect.bottom > vh - VIEWPORT_MARGIN_PX) { - dy = vh - VIEWPORT_MARGIN_PX - rect.bottom; - } + // Vertical clamping intentionally omitted — the popover should stay + // anchored to its trigger and render out of view rather than detaching + // upward to stay in the viewport. The user can scroll to reveal it. - if (dx !== 0 || dy !== 0) { - el.style.translate = `${dx}px ${dy}px`; + if (dx !== 0) { + el.style.translate = `${dx}px 0px`; } } diff --git a/src/rendering/html/styles.ts b/src/rendering/html/styles.ts index f4b00ef8..41dc28a3 100644 --- a/src/rendering/html/styles.ts +++ b/src/rendering/html/styles.ts @@ -28,7 +28,7 @@ const STATUS_COLORS: { light: ThemeColors; dark: ThemeColors } = { verified: { text: "#34d399", bg: "#022c22", border: "#065f46" }, partial: { text: "#fbbf24", bg: "#451a03", border: "#92400e" }, notFound: { text: "#f87171", bg: "#450a0a", border: "#991b1b" }, - pending: { text: "#9ca3af", bg: "#1f2937", border: "#374151" }, + pending: { text: "#a1a1aa", bg: "#27272a", border: "#3f3f46" }, }, }; diff --git a/src/rendering/testing/HtmlPreview.tsx b/src/rendering/testing/HtmlPreview.tsx index c58abdb0..7dfa19ae 100644 --- a/src/rendering/testing/HtmlPreview.tsx +++ b/src/rendering/testing/HtmlPreview.tsx @@ -82,19 +82,19 @@ function getPreviewCss(): string { ".dc-preview-scope .dc-verified { color: #10b981; }", ".dc-preview-scope .dc-partial { color: #f59e0b; }", ".dc-preview-scope .dc-not-found { color: #ef4444; }", - ".dc-preview-scope .dc-pending { color: #9ca3af; }", + ".dc-preview-scope .dc-pending { color: #a1a1aa; }", ".dc-preview-scope span.dc-citation.dc-verified { text-decoration: underline solid #10b981; text-underline-offset: 3px; }", ".dc-preview-scope span.dc-citation.dc-partial { text-decoration: underline dashed #f59e0b; text-underline-offset: 3px; }", ".dc-preview-scope span.dc-citation.dc-not-found { text-decoration: underline wavy #ef4444; text-underline-offset: 3px; }", - ".dc-preview-scope span.dc-citation.dc-pending { text-decoration: underline dotted #9ca3af; text-underline-offset: 3px; }", + ".dc-preview-scope span.dc-citation.dc-pending { text-decoration: underline dotted #a1a1aa; text-underline-offset: 3px; }", ".dc-preview-scope .dc-brackets { font-family: monospace; font-size: 0.85em; }", - ".dc-preview-scope .dc-chip { display: inline-flex; align-items: center; gap: 2px; padding: 1px 8px; border-radius: 9999px; font-size: 0.85em; background: #f3f4f6; border: 1px solid #e5e7eb; }", + ".dc-preview-scope .dc-chip { display: inline-flex; align-items: center; gap: 2px; padding: 1px 8px; border-radius: 9999px; font-size: 0.85em; background: #f4f4f5; border: 1px solid #e4e4e7; }", ".dc-preview-scope .dc-chip.dc-verified { background: #f0fdf4; border-color: #bbf7d0; }", ".dc-preview-scope .dc-chip.dc-partial { background: #fffbeb; border-color: #fde68a; }", ".dc-preview-scope .dc-chip.dc-not-found { background: #fef2f2; border-color: #fecaca; }", - ".dc-preview-scope .dc-chip.dc-pending { background: #f9fafb; border-color: #e5e7eb; }", + ".dc-preview-scope .dc-chip.dc-pending { background: #fafafa; border-color: #e4e4e7; }", ".dc-preview-scope .dc-tooltip-wrap { position: relative; display: inline; }", - ".dc-preview-scope .dc-tooltip { display: none; position: absolute; bottom: 100%; left: 50%; transform: translateX(-50%); background: #1f2937; color: #f9fafb; padding: 6px 10px; border-radius: 6px; font-size: 12px; white-space: nowrap; z-index: 10; margin-bottom: 4px; }", + ".dc-preview-scope .dc-tooltip { display: none; position: absolute; bottom: 100%; left: 50%; transform: translateX(-50%); background: #27272a; color: #fafafa; padding: 6px 10px; border-radius: 6px; font-size: 12px; white-space: nowrap; z-index: 10; margin-bottom: 4px; }", ".dc-preview-scope .dc-tooltip-wrap:hover .dc-tooltip { display: block; }", ].join("\n"); } @@ -259,7 +259,7 @@ function selfContainedHtml(): string { " .dc-verified { color: #10b981; }", " .dc-partial { color: #f59e0b; }", " .dc-not-found { color: #ef4444; }", - " .dc-pending { color: #9ca3af; }", + " .dc-pending { color: #a1a1aa; }", " .dc-brackets { font-family: monospace; font-size: 0.85em; }", "", "", diff --git a/src/vanilla/popoverStyles.ts b/src/vanilla/popoverStyles.ts index eda24282..15451cc5 100644 --- a/src/vanilla/popoverStyles.ts +++ b/src/vanilla/popoverStyles.ts @@ -2,6 +2,20 @@ * CSS for the vanilla popover runtime. * Self-contained — no Tailwind dependency. */ +const DARK_VARS = `--dc-pop-bg: #27272a; + --dc-pop-text: #fafafa; + --dc-pop-border: #3f3f46; + --dc-pop-muted: #a1a1aa; + --dc-pop-image-bg: #18181b; + --dc-pop-verified-bg: #052e16; + --dc-pop-verified-text: #4ade80; + --dc-pop-partial-bg: #451a03; + --dc-pop-partial-text: #fbbf24; + --dc-pop-notfound-bg: #450a0a; + --dc-pop-notfound-text: #f87171; + --dc-pop-pending-bg: #27272a; + --dc-pop-pending-text: #a1a1aa;`; + export const POPOVER_CSS = ` /* ── Popover container ── */ .dc-popover { @@ -16,8 +30,8 @@ export const POPOVER_CSS = ` font-size: 14px; line-height: 1.5; background: var(--dc-pop-bg, #fff); - color: var(--dc-pop-text, #1f2937); - border: 1px solid var(--dc-pop-border, #e5e7eb); + color: var(--dc-pop-text, #27272a); + border: 1px solid var(--dc-pop-border, #e4e4e7); } .dc-pop-content { @@ -30,7 +44,7 @@ export const POPOVER_CSS = ` padding: 8px 12px; font-weight: 600; font-size: 13px; - border-bottom: 1px solid var(--dc-pop-border, #e5e7eb); + border-bottom: 1px solid var(--dc-pop-border, #e4e4e7); } .dc-pop-verified { @@ -49,16 +63,16 @@ export const POPOVER_CSS = ` } .dc-pop-pending { - background: var(--dc-pop-pending-bg, #f9fafb); - color: var(--dc-pop-pending-text, #6b7280); + background: var(--dc-pop-pending-bg, #fafafa); + color: var(--dc-pop-pending-text, #71717a); } /* ── Source label ── */ .dc-pop-source { padding: 6px 12px; font-size: 12px; - color: var(--dc-pop-muted, #6b7280); - border-bottom: 1px solid var(--dc-pop-border, #e5e7eb); + color: var(--dc-pop-muted, #71717a); + border-bottom: 1px solid var(--dc-pop-border, #e4e4e7); } /* ── Quote blockquote ── */ @@ -67,8 +81,8 @@ export const POPOVER_CSS = ` padding: 8px 12px; font-style: italic; font-size: 13px; - color: var(--dc-pop-muted, #6b7280); - border-left: 3px solid var(--dc-pop-border, #e5e7eb); + color: var(--dc-pop-muted, #71717a); + border-left: 3px solid var(--dc-pop-border, #e4e4e7); } /* ── Evidence image ── */ @@ -77,8 +91,8 @@ export const POPOVER_CSS = ` width: 100%; max-height: 200px; object-fit: contain; - border-top: 1px solid var(--dc-pop-border, #e5e7eb); - background: var(--dc-pop-image-bg, #f9fafb); + border-top: 1px solid var(--dc-pop-border, #e4e4e7); + background: var(--dc-pop-image-bg, #fafafa); } /* ── Expanded image overlay ── */ @@ -103,37 +117,13 @@ export const POPOVER_CSS = ` /* ── Dark theme via custom properties ── */ [data-dc-theme="dark"] { - --dc-pop-bg: #1f2937; - --dc-pop-text: #f9fafb; - --dc-pop-border: #374151; - --dc-pop-muted: #9ca3af; - --dc-pop-image-bg: #111827; - --dc-pop-verified-bg: #052e16; - --dc-pop-verified-text: #4ade80; - --dc-pop-partial-bg: #451a03; - --dc-pop-partial-text: #fbbf24; - --dc-pop-notfound-bg: #450a0a; - --dc-pop-notfound-text: #f87171; - --dc-pop-pending-bg: #1f2937; - --dc-pop-pending-text: #9ca3af; + ${DARK_VARS} } /* ── Auto theme (follow system) ── */ @media (prefers-color-scheme: dark) { [data-dc-theme="auto"] { - --dc-pop-bg: #1f2937; - --dc-pop-text: #f9fafb; - --dc-pop-border: #374151; - --dc-pop-muted: #9ca3af; - --dc-pop-image-bg: #111827; - --dc-pop-verified-bg: #052e16; - --dc-pop-verified-text: #4ade80; - --dc-pop-partial-bg: #451a03; - --dc-pop-partial-text: #fbbf24; - --dc-pop-notfound-bg: #450a0a; - --dc-pop-notfound-text: #f87171; - --dc-pop-pending-bg: #1f2937; - --dc-pop-pending-text: #9ca3af; + ${DARK_VARS} } } @@ -145,6 +135,6 @@ export const POPOVER_CSS = ` font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; font-size: 16px; line-height: 1.7; - color: var(--dc-pop-text, #1f2937); + color: var(--dc-pop-text, #27272a); } `; diff --git a/tests/playwright/specs/GeneratedImageCitation.tsx b/tests/playwright/specs/GeneratedImageCitation.tsx index bbc43df1..5c3a5745 100644 --- a/tests/playwright/specs/GeneratedImageCitation.tsx +++ b/tests/playwright/specs/GeneratedImageCitation.tsx @@ -23,9 +23,9 @@ function createCanvasDataUrl(width: number, height: number): string { if (!ctx) { return "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mO8cuXKfwYGBgYGAAi7Av7W3NgAAAAASUVORK5CYII="; } - ctx.fillStyle = "#f3f4f6"; + ctx.fillStyle = "#f4f4f5"; ctx.fillRect(0, 0, width, height); - ctx.fillStyle = "#111827"; + ctx.fillStyle = "#18181b"; ctx.font = "20px sans-serif"; ctx.fillText("DeepCitation proof", 20, 40); return canvas.toDataURL("image/png"); diff --git a/tests/playwright/specs/PageExpandGeometryCitation.tsx b/tests/playwright/specs/PageExpandGeometryCitation.tsx index 08fa67d0..e5155b93 100644 --- a/tests/playwright/specs/PageExpandGeometryCitation.tsx +++ b/tests/playwright/specs/PageExpandGeometryCitation.tsx @@ -24,9 +24,9 @@ function createCanvasDataUrl(width: number, height: number, label: string): stri if (!ctx) { return "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mO8cuXKfwYGBgYGAAi7Av7W3NgAAAAASUVORK5CYII="; } - ctx.fillStyle = "#f3f4f6"; + ctx.fillStyle = "#f4f4f5"; ctx.fillRect(0, 0, width, height); - ctx.fillStyle = "#111827"; + ctx.fillStyle = "#18181b"; ctx.font = `${Math.max(16, Math.floor(height * 0.18))}px sans-serif`; ctx.fillText(label, 20, Math.max(30, Math.floor(height * 0.45))); ctx.strokeStyle = "#2563eb"; diff --git a/tests/playwright/specs/__snapshots__/renderTargetShowcase.spec.tsx-snapshots/render-target-showcase-chromium-linux.avif b/tests/playwright/specs/__snapshots__/renderTargetShowcase.spec.tsx-snapshots/render-target-showcase-chromium-linux.avif index 84d59927..3e92198a 100644 Binary files a/tests/playwright/specs/__snapshots__/renderTargetShowcase.spec.tsx-snapshots/render-target-showcase-chromium-linux.avif and b/tests/playwright/specs/__snapshots__/renderTargetShowcase.spec.tsx-snapshots/render-target-showcase-chromium-linux.avif differ diff --git a/tests/playwright/specs/__snapshots__/renderTargetShowcase.spec.tsx-snapshots/render-target-showcase-dark-chromium-linux.avif b/tests/playwright/specs/__snapshots__/renderTargetShowcase.spec.tsx-snapshots/render-target-showcase-dark-chromium-linux.avif index 1750fb47..ab527306 100644 Binary files a/tests/playwright/specs/__snapshots__/renderTargetShowcase.spec.tsx-snapshots/render-target-showcase-dark-chromium-linux.avif and b/tests/playwright/specs/__snapshots__/renderTargetShowcase.spec.tsx-snapshots/render-target-showcase-dark-chromium-linux.avif differ diff --git a/tests/playwright/specs/__snapshots__/renderTargetShowcase.spec.tsx-snapshots/render-target-showcase-mobile-chromium-linux.avif b/tests/playwright/specs/__snapshots__/renderTargetShowcase.spec.tsx-snapshots/render-target-showcase-mobile-chromium-linux.avif index 99ea1732..93221f9c 100644 Binary files a/tests/playwright/specs/__snapshots__/renderTargetShowcase.spec.tsx-snapshots/render-target-showcase-mobile-chromium-linux.avif and b/tests/playwright/specs/__snapshots__/renderTargetShowcase.spec.tsx-snapshots/render-target-showcase-mobile-chromium-linux.avif differ diff --git a/tests/playwright/specs/__snapshots__/renderTargetShowcase.spec.tsx-snapshots/render-target-showcase-tablet-chromium-linux.avif b/tests/playwright/specs/__snapshots__/renderTargetShowcase.spec.tsx-snapshots/render-target-showcase-tablet-chromium-linux.avif index f2d6355e..62e8f225 100644 Binary files a/tests/playwright/specs/__snapshots__/renderTargetShowcase.spec.tsx-snapshots/render-target-showcase-tablet-chromium-linux.avif and b/tests/playwright/specs/__snapshots__/renderTargetShowcase.spec.tsx-snapshots/render-target-showcase-tablet-chromium-linux.avif differ diff --git a/tests/playwright/specs/citationPopoverInteractions.spec.tsx b/tests/playwright/specs/citationPopoverInteractions.spec.tsx index 16d5c795..246932e4 100644 --- a/tests/playwright/specs/citationPopoverInteractions.spec.tsx +++ b/tests/playwright/specs/citationPopoverInteractions.spec.tsx @@ -346,8 +346,8 @@ test.describe("Citation Popover - Click-to-Close Behavior", () => { }).length; const minInlineOpacity = samples.inlineOpacity.length > 0 ? Math.min(...samples.inlineOpacity) : 1; - // Ensure this scenario actually hits viewport guard correction while expanding. - expect(guardActiveFrameCount).toBeGreaterThan(0); + // Vertical clamping was removed — the guard should no longer fire dy corrections. + expect(guardActiveFrameCount).toBe(0); // Allow a single direction change (settle) but prevent repeated up/down oscillation. expect(reversals).toBeLessThanOrEqual(1); // Prevent a "same-width teleport left" frame before width expansion starts. diff --git a/tests/playwright/specs/expandedPagePopover.spec.tsx b/tests/playwright/specs/expandedPagePopover.spec.tsx index 60b6c524..493c431b 100644 --- a/tests/playwright/specs/expandedPagePopover.spec.tsx +++ b/tests/playwright/specs/expandedPagePopover.spec.tsx @@ -159,7 +159,7 @@ test.describe("Expanded-Page Basics", () => { const { expandedView } = await expandToFullPage(page); const backgroundColor = await expandedView.evaluate(el => window.getComputedStyle(el as HTMLElement).backgroundColor); - expect(backgroundColor).toBe("rgb(243, 244, 246)"); + expect(backgroundColor).toBe("rgb(244, 244, 245)"); const expandedImage = expandedView.locator("img").first(); await expect(expandedImage).toBeVisible(); @@ -215,7 +215,7 @@ test.describe("Expanded-Page Basics - Dark Mode", () => { const { expandedView } = await expandToFullPage(page); const backgroundColor = await expandedView.evaluate(el => window.getComputedStyle(el as HTMLElement).backgroundColor); - expect(backgroundColor).toBe("rgb(31, 41, 55)"); + expect(backgroundColor).toBe("rgb(39, 39, 42)"); }); }); diff --git a/tests/playwright/specs/popoverImageWidth.spec.tsx b/tests/playwright/specs/popoverImageWidth.spec.tsx index be003092..01f6e250 100644 --- a/tests/playwright/specs/popoverImageWidth.spec.tsx +++ b/tests/playwright/specs/popoverImageWidth.spec.tsx @@ -119,7 +119,7 @@ test.describe("Popover Image Keyhole Strip", () => { await expect(strip).toBeVisible(); const backgroundColor = await strip.evaluate(el => window.getComputedStyle(el as HTMLElement).backgroundColor); - expect(backgroundColor).toBe("rgb(243, 244, 246)"); + expect(backgroundColor).toBe("rgb(244, 244, 245)"); }); test("image renders at natural scale (not squashed)", async ({ mount, page }) => { @@ -223,7 +223,7 @@ test.describe("Popover Image Keyhole Strip - Dark Mode", () => { await expect(strip).toBeVisible(); const backgroundColor = await strip.evaluate(el => window.getComputedStyle(el as HTMLElement).backgroundColor); - expect(backgroundColor).toBe("rgb(31, 41, 55)"); + expect(backgroundColor).toBe("rgb(39, 39, 42)"); }); });