Spaces:
Sleeping
Sleeping
Rebrand review UI and move to Groq gpt-oss models
Browse files- config/settings.yaml +2 -2
- src/agents.py +15 -2
- ui/index.html +6 -2
- ui/public/favicon.svg +1 -0
- ui/src/Brand.tsx +56 -0
- ui/src/FieldRow.tsx +110 -123
- ui/src/PDFPane.tsx +29 -25
- ui/src/RecordPane.tsx +58 -13
- ui/src/ReviewDashboard.tsx +45 -49
- ui/src/SessionPage.tsx +6 -8
- ui/src/UploadPage.tsx +142 -211
- ui/src/index.css +46 -17
- ui/tailwind.config.js +39 -9
config/settings.yaml
CHANGED
|
@@ -10,9 +10,9 @@
|
|
| 10 |
|
| 11 |
llm:
|
| 12 |
# Model served by Groq. Override at runtime via GROQ_MODEL env var.
|
| 13 |
-
model: "
|
| 14 |
# Fast model for document classification. Override via GROQ_CLASSIFIER_MODEL env var.
|
| 15 |
-
classifier_model: "llama-3.1-8b-instant
|
| 16 |
# Number of instructor self-correction retries on Pydantic validation failure.
|
| 17 |
max_retries: 2
|
| 18 |
|
|
|
|
| 10 |
|
| 11 |
llm:
|
| 12 |
# Model served by Groq. Override at runtime via GROQ_MODEL env var.
|
| 13 |
+
model: "openai/gpt-oss-120b" # llama-4-scout was retired by Groq on 2026-07-17
|
| 14 |
# Fast model for document classification. Override via GROQ_CLASSIFIER_MODEL env var.
|
| 15 |
+
classifier_model: "openai/gpt-oss-20b" # llama-3.1-8b-instant retired 2026-08-16
|
| 16 |
# Number of instructor self-correction retries on Pydantic validation failure.
|
| 17 |
max_retries: 2
|
| 18 |
|
src/agents.py
CHANGED
|
@@ -63,6 +63,17 @@ _EXTRACTION_MODEL: str = settings.llm.model
|
|
| 63 |
_CLASSIFIER_MODEL: str = settings.llm.classifier_model
|
| 64 |
|
| 65 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 66 |
def _build_docling_converter() -> DocumentConverter:
|
| 67 |
"""Build a DocumentConverter configured from settings.docling."""
|
| 68 |
opts = PdfPipelineOptions()
|
|
@@ -231,10 +242,11 @@ class InsuranceExtractionAgents:
|
|
| 231 |
"content": "Classify this document:\n\n" + markdown_text[:4000],
|
| 232 |
},
|
| 233 |
],
|
| 234 |
-
max_tokens=10,
|
| 235 |
temperature=0,
|
|
|
|
| 236 |
)
|
| 237 |
-
|
|
|
|
| 238 |
valid = {"Schedule", "Certificate", "StatementOfFact", "PolicyBooklet", "Unknown"}
|
| 239 |
if llm_result in valid:
|
| 240 |
if llm_result != keyword_result:
|
|
@@ -494,6 +506,7 @@ class InsuranceExtractionAgents:
|
|
| 494 |
model=_EXTRACTION_MODEL,
|
| 495 |
response_model=UKMotorGoldenRecord,
|
| 496 |
max_retries=self._max_retries,
|
|
|
|
| 497 |
messages=[
|
| 498 |
{"role": "system", "content": system_prompt.strip()},
|
| 499 |
{"role": "user", "content": user_message},
|
|
|
|
| 63 |
_CLASSIFIER_MODEL: str = settings.llm.classifier_model
|
| 64 |
|
| 65 |
|
| 66 |
+
def _reasoning_kwargs(model: str, max_tokens: int) -> dict:
|
| 67 |
+
"""Extra Groq params for reasoning models (gpt-oss), which spend output tokens thinking.
|
| 68 |
+
|
| 69 |
+
Without a larger budget and low reasoning effort they truncate structured output
|
| 70 |
+
("output is incomplete due to max_tokens") or return an empty classification.
|
| 71 |
+
"""
|
| 72 |
+
if "gpt-oss" in model:
|
| 73 |
+
return {"max_tokens": max_tokens, "reasoning_effort": "low"}
|
| 74 |
+
return {}
|
| 75 |
+
|
| 76 |
+
|
| 77 |
def _build_docling_converter() -> DocumentConverter:
|
| 78 |
"""Build a DocumentConverter configured from settings.docling."""
|
| 79 |
opts = PdfPipelineOptions()
|
|
|
|
| 242 |
"content": "Classify this document:\n\n" + markdown_text[:4000],
|
| 243 |
},
|
| 244 |
],
|
|
|
|
| 245 |
temperature=0,
|
| 246 |
+
**({"max_tokens": 10} | _reasoning_kwargs(_CLASSIFIER_MODEL, 256)),
|
| 247 |
)
|
| 248 |
+
content = (response.choices[0].message.content or "").strip()
|
| 249 |
+
llm_result = content.split()[0].strip(".,") if content else ""
|
| 250 |
valid = {"Schedule", "Certificate", "StatementOfFact", "PolicyBooklet", "Unknown"}
|
| 251 |
if llm_result in valid:
|
| 252 |
if llm_result != keyword_result:
|
|
|
|
| 506 |
model=_EXTRACTION_MODEL,
|
| 507 |
response_model=UKMotorGoldenRecord,
|
| 508 |
max_retries=self._max_retries,
|
| 509 |
+
**_reasoning_kwargs(_EXTRACTION_MODEL, 4096),
|
| 510 |
messages=[
|
| 511 |
{"role": "system", "content": system_prompt.strip()},
|
| 512 |
{"role": "user", "content": user_message},
|
ui/index.html
CHANGED
|
@@ -2,9 +2,13 @@
|
|
| 2 |
<html lang="en">
|
| 3 |
<head>
|
| 4 |
<meta charset="UTF-8" />
|
| 5 |
-
<link rel="icon" type="image/svg+xml" href="/
|
| 6 |
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
| 7 |
-
<
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
</head>
|
| 9 |
<body>
|
| 10 |
<div id="root"></div>
|
|
|
|
| 2 |
<html lang="en">
|
| 3 |
<head>
|
| 4 |
<meta charset="UTF-8" />
|
| 5 |
+
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
| 6 |
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
| 7 |
+
<meta name="theme-color" content="#121826" />
|
| 8 |
+
<meta name="description" content="PolicyTrace: reviewable Document AI for UK motor insurance PDF packs. A reference system from ai·toolstack." />
|
| 9 |
+
<link rel="preconnect" href="https://cdn.jsdelivr.net" crossorigin />
|
| 10 |
+
<link rel="preload" as="font" type="font/woff2" href="https://cdn.jsdelivr.net/npm/geist@1.8.1/dist/fonts/geist-sans/Geist-Variable.woff2" crossorigin />
|
| 11 |
+
<title>PolicyTrace · ai·toolstack</title>
|
| 12 |
</head>
|
| 13 |
<body>
|
| 14 |
<div id="root"></div>
|
ui/public/favicon.svg
ADDED
|
|
ui/src/Brand.tsx
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/** ai·toolstack brand primitives shared by every PolicyTrace screen. */
|
| 2 |
+
|
| 3 |
+
export function Mark({ size = 24 }: { size?: number }) {
|
| 4 |
+
return (
|
| 5 |
+
<svg width={size} height={size} viewBox="0 0 56 56" aria-hidden="true" focusable="false">
|
| 6 |
+
<rect x="0.5" y="0.5" width="55" height="55" rx="12" fill="#1F2940" stroke="#2C3752" />
|
| 7 |
+
<rect x="12" y="14" width="32" height="6" rx="2" fill="#E0A946" />
|
| 8 |
+
<rect x="16" y="25" width="28" height="6" rx="2" fill="#ECEAE4" />
|
| 9 |
+
<rect x="12" y="36" width="32" height="6" rx="2" fill="#56617A" />
|
| 10 |
+
</svg>
|
| 11 |
+
)
|
| 12 |
+
}
|
| 13 |
+
|
| 14 |
+
export function Lockup({ product = 'PolicyTrace' }: { product?: string }) {
|
| 15 |
+
return (
|
| 16 |
+
<div className="flex items-center gap-3 min-w-0">
|
| 17 |
+
<a
|
| 18 |
+
href="https://www.ai-toolstack.com/"
|
| 19 |
+
target="_blank"
|
| 20 |
+
rel="noopener noreferrer"
|
| 21 |
+
className="flex items-center gap-2.5 text-ink-100"
|
| 22 |
+
aria-label="ai·toolstack home"
|
| 23 |
+
>
|
| 24 |
+
<Mark size={26} />
|
| 25 |
+
<span className="text-[17px] font-semibold tracking-[-0.035em] hidden sm:inline">
|
| 26 |
+
<span className="font-mono font-medium text-ink-300">ai·</span>toolstack
|
| 27 |
+
</span>
|
| 28 |
+
</a>
|
| 29 |
+
<span className="text-ink-600 select-none hidden sm:inline" aria-hidden="true">/</span>
|
| 30 |
+
<span className="text-[15px] font-semibold tracking-[-0.02em] text-ink-100 truncate">{product}</span>
|
| 31 |
+
</div>
|
| 32 |
+
)
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
export function ExternalIcon() {
|
| 36 |
+
return (
|
| 37 |
+
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8"
|
| 38 |
+
strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
| 39 |
+
<path d="M14 4h6v6M20 4l-9 9M18 14v5a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V7a1 1 0 0 1 1-1h5" />
|
| 40 |
+
</svg>
|
| 41 |
+
)
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
export function Spinner({ className = 'h-4 w-4' }: { className?: string }) {
|
| 45 |
+
return (
|
| 46 |
+
<svg className={`animate-spin ${className}`} viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
| 47 |
+
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="3" />
|
| 48 |
+
<path className="opacity-90" fill="currentColor" d="M4 12a8 8 0 018-8v3a5 5 0 00-5 5H4z" />
|
| 49 |
+
</svg>
|
| 50 |
+
)
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
export const LINKS = {
|
| 54 |
+
chapters: 'https://www.ai-toolstack.com/projects/policytrace/',
|
| 55 |
+
github: 'https://github.com/AItoolstack/ai-policytrace',
|
| 56 |
+
}
|
ui/src/FieldRow.tsx
CHANGED
|
@@ -1,4 +1,4 @@
|
|
| 1 |
-
import {
|
| 2 |
import type { FieldEntry, FieldReview } from './types'
|
| 3 |
import { useStore } from './store'
|
| 4 |
|
|
@@ -21,19 +21,19 @@ export function FieldRow({ entry, sessionId, isActive, review, onClick }: Props)
|
|
| 21 |
? review.overridden_value
|
| 22 |
: entry.value
|
| 23 |
|
| 24 |
-
const isVerified
|
| 25 |
-
const isRejected
|
| 26 |
const isOverridden = review?.action === 'override'
|
| 27 |
|
| 28 |
-
const
|
| 29 |
-
?
|
| 30 |
: isRejected
|
| 31 |
-
?
|
| 32 |
: isOverridden
|
| 33 |
-
?
|
| 34 |
: isActive
|
| 35 |
-
?
|
| 36 |
-
:
|
| 37 |
|
| 38 |
const handleSaveOverride = async () => {
|
| 39 |
await overrideField(sessionId, entry.fieldPath, editValue)
|
|
@@ -42,39 +42,29 @@ export function FieldRow({ entry, sessionId, isActive, review, onClick }: Props)
|
|
| 42 |
|
| 43 |
return (
|
| 44 |
<div
|
| 45 |
-
className=
|
| 46 |
-
style={borderStyle}
|
| 47 |
onClick={onClick}
|
|
|
|
|
|
|
|
|
|
|
|
|
| 48 |
>
|
| 49 |
-
<div className="flex items-start gap-
|
| 50 |
-
{/* Label + value */}
|
| 51 |
<div className="flex-1 min-w-0">
|
| 52 |
<div className="flex items-center gap-2 flex-wrap">
|
| 53 |
-
<span className="text-
|
| 54 |
-
|
| 55 |
-
</
|
| 56 |
-
{
|
| 57 |
-
<span className="inline-flex items-center gap-0.5 text-xs text-green-700 font-medium">
|
| 58 |
-
<CheckIcon /> Verified
|
| 59 |
-
</span>
|
| 60 |
-
)}
|
| 61 |
-
{isOverridden && (
|
| 62 |
-
<span className="text-xs text-blue-700 font-medium">Overridden</span>
|
| 63 |
-
)}
|
| 64 |
-
{isRejected && (
|
| 65 |
-
<span className="text-xs text-red-600 font-medium">Flagged</span>
|
| 66 |
-
)}
|
| 67 |
</div>
|
| 68 |
|
| 69 |
-
{/* Value */}
|
| 70 |
{editing ? (
|
| 71 |
-
<div
|
| 72 |
-
className="
|
| 73 |
-
onClick={(e) => e.stopPropagation()}
|
| 74 |
-
>
|
| 75 |
<input
|
|
|
|
| 76 |
autoFocus
|
| 77 |
-
className="flex-1 text-
|
| 78 |
value={editValue}
|
| 79 |
onChange={(e) => setEditValue(e.target.value)}
|
| 80 |
onKeyDown={(e) => {
|
|
@@ -82,120 +72,117 @@ export function FieldRow({ entry, sessionId, isActive, review, onClick }: Props)
|
|
| 82 |
if (e.key === 'Escape') setEditing(false)
|
| 83 |
}}
|
| 84 |
/>
|
| 85 |
-
<button
|
| 86 |
-
|
| 87 |
-
className="text-xs px-2 py-1 bg-blue-600 text-white rounded hover:bg-blue-700"
|
| 88 |
-
>
|
| 89 |
-
Save
|
| 90 |
-
</button>
|
| 91 |
-
<button
|
| 92 |
-
onClick={() => setEditing(false)}
|
| 93 |
-
className="text-xs px-2 py-1 bg-gray-200 text-gray-700 rounded hover:bg-gray-300"
|
| 94 |
-
>
|
| 95 |
-
Cancel
|
| 96 |
-
</button>
|
| 97 |
</div>
|
| 98 |
) : (
|
| 99 |
-
<p className="text-
|
| 100 |
-
{displayValue
|
| 101 |
-
|
| 102 |
-
|
| 103 |
</p>
|
| 104 |
)}
|
| 105 |
|
| 106 |
-
{/* Provenance source hint — or explicit "no location" notice */}
|
| 107 |
{!editing && (
|
| 108 |
entry.provenance ? (
|
| 109 |
-
<p className="text-
|
| 110 |
-
{entry.provenance.source_filename} · p.{entry.provenance.location.page} ·{' '}
|
| 111 |
-
<span className="italic">"{entry.provenance.matched_text.slice(0, 60)}{entry.provenance.matched_text.length > 60 ? '…' : ''}"</span>
|
| 112 |
</p>
|
| 113 |
) : (
|
| 114 |
-
<p className="text-
|
| 115 |
-
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded bg-gray-100 text-gray-400 font-medium">
|
| 116 |
-
<span aria-hidden>—</span> No location data
|
| 117 |
-
</span>
|
| 118 |
-
</p>
|
| 119 |
)
|
| 120 |
)}
|
| 121 |
</div>
|
| 122 |
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
<
|
| 134 |
-
|
| 135 |
-
onClick={() =>
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
? 'bg-green-500 text-white'
|
| 139 |
-
: 'bg-gray-100 text-gray-500 hover:bg-green-100 hover:text-green-700'
|
| 140 |
-
}`}
|
| 141 |
-
>
|
| 142 |
-
✓
|
| 143 |
-
</button>
|
| 144 |
-
|
| 145 |
-
{/* Edit */}
|
| 146 |
-
<button
|
| 147 |
-
title="Override value"
|
| 148 |
-
onClick={() => {
|
| 149 |
-
setEditValue(displayValue ?? '')
|
| 150 |
-
setEditing(true)
|
| 151 |
-
}}
|
| 152 |
-
className="w-7 h-7 rounded flex items-center justify-center text-sm transition-colors"
|
| 153 |
-
style={{ backgroundColor: '#f3f4f6', color: '#6b7280' }}
|
| 154 |
-
onMouseEnter={e => { (e.currentTarget as HTMLElement).style.backgroundColor = '#eff6ff'; (e.currentTarget as HTMLElement).style.color = '#2563EB' }}
|
| 155 |
-
onMouseLeave={e => { (e.currentTarget as HTMLElement).style.backgroundColor = '#f3f4f6'; (e.currentTarget as HTMLElement).style.color = '#6b7280' }}
|
| 156 |
-
>
|
| 157 |
-
✎
|
| 158 |
-
</button>
|
| 159 |
-
|
| 160 |
-
{/* Flag */}
|
| 161 |
-
<button
|
| 162 |
-
title="Flag for review"
|
| 163 |
-
onClick={() => rejectField(sessionId, entry.fieldPath)}
|
| 164 |
-
className={`w-7 h-7 rounded flex items-center justify-center text-sm transition-colors ${
|
| 165 |
-
isRejected
|
| 166 |
-
? 'bg-red-500 text-white'
|
| 167 |
-
: 'bg-gray-100 text-gray-500 hover:bg-red-100 hover:text-red-600'
|
| 168 |
-
}`}
|
| 169 |
-
>
|
| 170 |
-
⚑
|
| 171 |
-
</button>
|
| 172 |
</div>
|
| 173 |
</div>
|
| 174 |
</div>
|
| 175 |
)
|
| 176 |
}
|
| 177 |
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 186 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 187 |
return (
|
| 188 |
-
<
|
| 189 |
-
|
| 190 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 191 |
)
|
| 192 |
}
|
| 193 |
|
| 194 |
-
function
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 195 |
return (
|
| 196 |
-
<
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
</svg>
|
| 200 |
)
|
| 201 |
}
|
|
|
|
| 1 |
+
import { useState } from 'react'
|
| 2 |
import type { FieldEntry, FieldReview } from './types'
|
| 3 |
import { useStore } from './store'
|
| 4 |
|
|
|
|
| 21 |
? review.overridden_value
|
| 22 |
: entry.value
|
| 23 |
|
| 24 |
+
const isVerified = review?.action === 'verify'
|
| 25 |
+
const isRejected = review?.action === 'reject'
|
| 26 |
const isOverridden = review?.action === 'override'
|
| 27 |
|
| 28 |
+
const shell = isVerified
|
| 29 |
+
? 'border-state-pass/40 bg-state-pass-tint'
|
| 30 |
: isRejected
|
| 31 |
+
? 'border-state-flag/45 bg-state-flag-tint'
|
| 32 |
: isOverridden
|
| 33 |
+
? 'border-state-override/40 bg-state-override-tint'
|
| 34 |
: isActive
|
| 35 |
+
? 'border-signal bg-paper-sheet ring-2 ring-signal/30'
|
| 36 |
+
: 'border-paper-rule bg-paper-sheet hover:border-[#CFCBC0]'
|
| 37 |
|
| 38 |
const handleSaveOverride = async () => {
|
| 39 |
await overrideField(sessionId, entry.fieldPath, editValue)
|
|
|
|
| 42 |
|
| 43 |
return (
|
| 44 |
<div
|
| 45 |
+
className={`group rounded-lg border px-3.5 py-2.5 cursor-pointer transition-colors shadow-sheet ${shell}`}
|
|
|
|
| 46 |
onClick={onClick}
|
| 47 |
+
role="button"
|
| 48 |
+
tabIndex={0}
|
| 49 |
+
aria-pressed={isActive}
|
| 50 |
+
onKeyDown={(e) => { if ((e.key === 'Enter' || e.key === ' ') && !editing) { e.preventDefault(); onClick() } }}
|
| 51 |
>
|
| 52 |
+
<div className="flex items-start gap-3">
|
|
|
|
| 53 |
<div className="flex-1 min-w-0">
|
| 54 |
<div className="flex items-center gap-2 flex-wrap">
|
| 55 |
+
<span className="text-[12px] font-medium text-paper-muted">{entry.label}</span>
|
| 56 |
+
{isVerified && <StatusPill tone="pass">Verified</StatusPill>}
|
| 57 |
+
{isOverridden && <StatusPill tone="override">Overridden</StatusPill>}
|
| 58 |
+
{isRejected && <StatusPill tone="flag">Flagged</StatusPill>}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 59 |
</div>
|
| 60 |
|
|
|
|
| 61 |
{editing ? (
|
| 62 |
+
<div className="flex gap-2 mt-1.5" onClick={(e) => e.stopPropagation()}>
|
| 63 |
+
<label className="sr-only" htmlFor={`edit-${entry.fieldPath}`}>New value for {entry.label}</label>
|
|
|
|
|
|
|
| 64 |
<input
|
| 65 |
+
id={`edit-${entry.fieldPath}`}
|
| 66 |
autoFocus
|
| 67 |
+
className="flex-1 min-w-0 text-[13px] text-paper-ink border border-paper-rule rounded-md px-2.5 py-1.5 bg-white focus:outline-none focus:border-signal focus:ring-2 focus:ring-signal/30"
|
| 68 |
value={editValue}
|
| 69 |
onChange={(e) => setEditValue(e.target.value)}
|
| 70 |
onKeyDown={(e) => {
|
|
|
|
| 72 |
if (e.key === 'Escape') setEditing(false)
|
| 73 |
}}
|
| 74 |
/>
|
| 75 |
+
<button onClick={handleSaveOverride} className="text-[12px] font-medium px-3 rounded-md bg-ink text-ink-100 hover:bg-ink-700">Save</button>
|
| 76 |
+
<button onClick={() => setEditing(false)} className="text-[12px] font-medium px-3 rounded-md border border-paper-rule text-paper-ink hover:bg-paper">Cancel</button>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 77 |
</div>
|
| 78 |
) : (
|
| 79 |
+
<p className="text-[14px] text-paper-ink mt-0.5 truncate font-medium" title={displayValue ?? undefined}>
|
| 80 |
+
{displayValue != null
|
| 81 |
+
? formatValue(displayValue, entry.fieldPath, isOverridden)
|
| 82 |
+
: <span className="text-paper-muted italic font-normal">Not extracted</span>}
|
| 83 |
</p>
|
| 84 |
)}
|
| 85 |
|
|
|
|
| 86 |
{!editing && (
|
| 87 |
entry.provenance ? (
|
| 88 |
+
<p className="mt-1 font-mono text-[11px] text-paper-muted truncate">
|
| 89 |
+
{shortName(entry.provenance.source_filename)} · p.{entry.provenance.location.page} · “{entry.provenance.matched_text.slice(0, 56)}{entry.provenance.matched_text.length > 56 ? '…' : ''}”
|
|
|
|
| 90 |
</p>
|
| 91 |
) : (
|
| 92 |
+
<p className="mt-1 font-mono text-[11px] text-[#9A4321]">no source location · check manually</p>
|
|
|
|
|
|
|
|
|
|
|
|
|
| 93 |
)
|
| 94 |
)}
|
| 95 |
</div>
|
| 96 |
|
| 97 |
+
<div className="flex items-center gap-1 flex-shrink-0" onClick={(e) => e.stopPropagation()}>
|
| 98 |
+
{entry.provenance && <ConfidenceBadge score={entry.provenance.match_score} />}
|
| 99 |
+
|
| 100 |
+
<IconButton label={`Verify ${entry.label}`} active={isVerified} tone="pass"
|
| 101 |
+
onClick={() => verifyField(sessionId, entry.fieldPath)}>
|
| 102 |
+
<path d="M5 12.5l4.5 4.5L19 7.5" />
|
| 103 |
+
</IconButton>
|
| 104 |
+
<IconButton label={`Override ${entry.label}`} active={isOverridden} tone="override"
|
| 105 |
+
onClick={() => { setEditValue(displayValue ?? ''); setEditing(true) }}>
|
| 106 |
+
<path d="M4 20h4L19 9a2.8 2.8 0 0 0-4-4L4 16v4z" />
|
| 107 |
+
</IconButton>
|
| 108 |
+
<IconButton label={`Flag ${entry.label} for review`} active={isRejected} tone="flag"
|
| 109 |
+
onClick={() => rejectField(sessionId, entry.fieldPath)}>
|
| 110 |
+
<path d="M5 21V4h11l-1.5 4L16 12H5" />
|
| 111 |
+
</IconButton>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 112 |
</div>
|
| 113 |
</div>
|
| 114 |
</div>
|
| 115 |
)
|
| 116 |
}
|
| 117 |
|
| 118 |
+
const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
|
| 119 |
+
const MONEY_PATH = /(excess|premium|optional_extras)/
|
| 120 |
+
|
| 121 |
+
/** Human-readable display of a stored value. The raw value stays in the title attribute. */
|
| 122 |
+
function formatValue(value: string, fieldPath: string, isOverride: boolean): string {
|
| 123 |
+
if (isOverride) return value
|
| 124 |
+
const dt = value.match(/^(\d{4})-(\d{2})-(\d{2})(?:T(\d{2}):(\d{2}))?/)
|
| 125 |
+
if (dt) {
|
| 126 |
+
const [, y, m, d, hh, mm] = dt
|
| 127 |
+
const date = `${Number(d)} ${MONTHS[Number(m) - 1]} ${y}`
|
| 128 |
+
return hh != null ? `${date}, ${hh}:${mm}` : date
|
| 129 |
+
}
|
| 130 |
+
if (value === 'true') return 'Yes'
|
| 131 |
+
if (value === 'false') return 'No'
|
| 132 |
+
if (MONEY_PATH.test(fieldPath) && /^-?\d+(\.\d+)?$/.test(value)) {
|
| 133 |
+
return `£${Number(value).toLocaleString('en-GB', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`
|
| 134 |
+
}
|
| 135 |
+
if (fieldPath.endsWith('annual_mileage') && /^\d+$/.test(value)) {
|
| 136 |
+
return `${Number(value).toLocaleString('en-GB')} miles`
|
| 137 |
+
}
|
| 138 |
+
return value
|
| 139 |
+
}
|
| 140 |
+
|
| 141 |
+
function shortName(filename: string) {
|
| 142 |
+
return filename.replace(/\.pdf$/i, '').replace(/ - Demo$/i, '')
|
| 143 |
+
}
|
| 144 |
|
| 145 |
+
const TONE = {
|
| 146 |
+
pass: { on: 'bg-state-pass text-white', hover: 'hover:text-state-pass hover:bg-state-pass-tint' },
|
| 147 |
+
override: { on: 'bg-state-override text-white', hover: 'hover:text-state-override hover:bg-state-override-tint' },
|
| 148 |
+
flag: { on: 'bg-state-flag text-white', hover: 'hover:text-state-flag hover:bg-state-flag-tint' },
|
| 149 |
+
} as const
|
| 150 |
+
|
| 151 |
+
function IconButton({ label, active, tone, onClick, children }: {
|
| 152 |
+
label: string; active: boolean; tone: keyof typeof TONE; onClick: () => void; children: React.ReactNode
|
| 153 |
+
}) {
|
| 154 |
return (
|
| 155 |
+
<button
|
| 156 |
+
type="button"
|
| 157 |
+
title={label}
|
| 158 |
+
aria-label={label}
|
| 159 |
+
aria-pressed={active}
|
| 160 |
+
onClick={onClick}
|
| 161 |
+
className={`h-8 w-8 grid place-items-center rounded-md transition-colors ${
|
| 162 |
+
active ? TONE[tone].on : `text-paper-muted ${TONE[tone].hover}`
|
| 163 |
+
}`}
|
| 164 |
+
>
|
| 165 |
+
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"
|
| 166 |
+
strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">{children}</svg>
|
| 167 |
+
</button>
|
| 168 |
)
|
| 169 |
}
|
| 170 |
|
| 171 |
+
function StatusPill({ tone, children }: { tone: keyof typeof TONE; children: React.ReactNode }) {
|
| 172 |
+
const cls = tone === 'pass' ? 'text-state-pass bg-state-pass/10'
|
| 173 |
+
: tone === 'override' ? 'text-state-override bg-state-override/10'
|
| 174 |
+
: 'text-state-flag bg-state-flag/10'
|
| 175 |
+
return <span className={`font-mono text-[10px] font-medium uppercase tracking-[0.08em] px-1.5 py-0.5 rounded ${cls}`}>{children}</span>
|
| 176 |
+
}
|
| 177 |
+
|
| 178 |
+
function ConfidenceBadge({ score }: { score: number }) {
|
| 179 |
+
const pct = Math.round(score * 100)
|
| 180 |
+
const cls = pct >= 90 ? 'text-state-pass border-state-pass/30'
|
| 181 |
+
: pct >= 75 ? 'text-signal-deep border-signal/50'
|
| 182 |
+
: 'text-state-flag border-state-flag/40'
|
| 183 |
return (
|
| 184 |
+
<span className={`font-mono text-[11px] px-1.5 py-0.5 rounded border bg-white mr-1 ${cls}`} title="Citation match score">
|
| 185 |
+
{pct}%
|
| 186 |
+
</span>
|
|
|
|
| 187 |
)
|
| 188 |
}
|
ui/src/PDFPane.tsx
CHANGED
|
@@ -36,10 +36,10 @@ export function PDFPane({ sessionId }: Props) {
|
|
| 36 |
const el = containerRef.current
|
| 37 |
if (!el) return
|
| 38 |
const obs = new ResizeObserver(([entry]) => {
|
| 39 |
-
setContainerWidth(Math.floor(entry.contentRect.width) -
|
| 40 |
})
|
| 41 |
obs.observe(el)
|
| 42 |
-
setContainerWidth(Math.floor(el.clientWidth) -
|
| 43 |
return () => obs.disconnect()
|
| 44 |
}, [])
|
| 45 |
|
|
@@ -100,28 +100,32 @@ export function PDFPane({ sessionId }: Props) {
|
|
| 100 |
return (
|
| 101 |
<div className="flex flex-col h-full">
|
| 102 |
{/* PDF file selector */}
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
|
|
|
|
|
|
| 106 |
<button
|
| 107 |
key={f}
|
|
|
|
|
|
|
| 108 |
onClick={() => setActivePdf(f)}
|
| 109 |
-
className=
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
: { backgroundColor: 'rgba(255,255,255,0.08)', border: '1px solid rgba(255,255,255,0.15)', color: 'rgba(255,255,255,0.7)' }
|
| 113 |
-
}
|
| 114 |
>
|
| 115 |
-
{f}
|
|
|
|
|
|
|
| 116 |
</button>
|
| 117 |
-
)
|
| 118 |
-
|
| 119 |
-
|
| 120 |
|
| 121 |
{/* PDF scroll area */}
|
| 122 |
<div
|
| 123 |
ref={containerRef}
|
| 124 |
-
className="flex-1 overflow-y-auto pdf-scroll-container bg-
|
| 125 |
>
|
| 126 |
{pdfUrl ? (
|
| 127 |
<Document
|
|
@@ -151,8 +155,8 @@ export function PDFPane({ sessionId }: Props) {
|
|
| 151 |
// Use block + explicit width so the overlay div always matches
|
| 152 |
// the canvas dimensions exactly (inline-block can shrink-wrap)
|
| 153 |
style={{ position: 'relative', width: containerWidth }}
|
| 154 |
-
className={`rounded
|
| 155 |
-
hasActive ? 'ring-4 ring-
|
| 156 |
}`}
|
| 157 |
>
|
| 158 |
<Page
|
|
@@ -181,14 +185,14 @@ export function PDFPane({ sessionId }: Props) {
|
|
| 181 |
width: `${x1 - x0}%`,
|
| 182 |
height: `${y1 - y0}%`,
|
| 183 |
background: isActive
|
| 184 |
-
? 'rgba(
|
| 185 |
-
: 'rgba(
|
| 186 |
border: isActive
|
| 187 |
-
? '
|
| 188 |
-
: '
|
| 189 |
borderRadius: 3,
|
| 190 |
boxShadow: isActive
|
| 191 |
-
? '0 0 0
|
| 192 |
: 'none',
|
| 193 |
transition: 'background 0.15s, border 0.15s',
|
| 194 |
}}
|
|
@@ -202,7 +206,7 @@ export function PDFPane({ sessionId }: Props) {
|
|
| 202 |
})}
|
| 203 |
</Document>
|
| 204 |
) : (
|
| 205 |
-
<div className="flex items-center justify-center h-full text-
|
| 206 |
No PDF selected
|
| 207 |
</div>
|
| 208 |
)}
|
|
@@ -213,7 +217,7 @@ export function PDFPane({ sessionId }: Props) {
|
|
| 213 |
|
| 214 |
function LoadingPlaceholder() {
|
| 215 |
return (
|
| 216 |
-
<div className="flex items-center justify-center p-12 text-
|
| 217 |
Loading PDF…
|
| 218 |
</div>
|
| 219 |
)
|
|
@@ -221,7 +225,7 @@ function LoadingPlaceholder() {
|
|
| 221 |
|
| 222 |
function ErrorPlaceholder() {
|
| 223 |
return (
|
| 224 |
-
<div className="flex items-center justify-center p-12 text-
|
| 225 |
Failed to load PDF.
|
| 226 |
</div>
|
| 227 |
)
|
|
|
|
| 36 |
const el = containerRef.current
|
| 37 |
if (!el) return
|
| 38 |
const obs = new ResizeObserver(([entry]) => {
|
| 39 |
+
setContainerWidth(Math.floor(entry.contentRect.width) - 48)
|
| 40 |
})
|
| 41 |
obs.observe(el)
|
| 42 |
+
setContainerWidth(Math.floor(el.clientWidth) - 48)
|
| 43 |
return () => obs.disconnect()
|
| 44 |
}, [])
|
| 45 |
|
|
|
|
| 100 |
return (
|
| 101 |
<div className="flex flex-col h-full">
|
| 102 |
{/* PDF file selector */}
|
| 103 |
+
<div className="flex items-center gap-1 px-3 h-[52px] border-b border-ink-700 bg-ink-800 flex-shrink-0 overflow-x-auto scroll-ink" role="tablist" aria-label="Source documents">
|
| 104 |
+
{pdfFiles.map((f) => {
|
| 105 |
+
const on = activePdfFile === f
|
| 106 |
+
const count = (sessionData?.provenance ?? []).filter((p) => p.source_filename === f).length
|
| 107 |
+
return (
|
| 108 |
<button
|
| 109 |
key={f}
|
| 110 |
+
role="tab"
|
| 111 |
+
aria-selected={on}
|
| 112 |
onClick={() => setActivePdf(f)}
|
| 113 |
+
className={`relative shrink-0 inline-flex items-center gap-2 h-9 px-3 rounded-md text-[13px] transition-colors ${
|
| 114 |
+
on ? 'bg-ink-700 text-ink-100' : 'text-ink-300 hover:text-ink-100 hover:bg-ink-700/60'
|
| 115 |
+
}`}
|
|
|
|
|
|
|
| 116 |
>
|
| 117 |
+
<span className="truncate max-w-[14rem]">{f.replace(/\.pdf$/i, '').replace(/ - Demo$/i, '')}</span>
|
| 118 |
+
<span className={`font-mono text-[11px] ${on ? 'text-signal' : 'text-ink-400'}`}>{count}</span>
|
| 119 |
+
{on && <span className="absolute left-3 right-3 -bottom-[9px] h-[2px] bg-signal rounded-full" aria-hidden="true" />}
|
| 120 |
</button>
|
| 121 |
+
)
|
| 122 |
+
})}
|
| 123 |
+
</div>
|
| 124 |
|
| 125 |
{/* PDF scroll area */}
|
| 126 |
<div
|
| 127 |
ref={containerRef}
|
| 128 |
+
className="flex-1 overflow-y-auto pdf-scroll-container scroll-ink grid-bg bg-ink-900 p-4 sm:p-6 space-y-6"
|
| 129 |
>
|
| 130 |
{pdfUrl ? (
|
| 131 |
<Document
|
|
|
|
| 155 |
// Use block + explicit width so the overlay div always matches
|
| 156 |
// the canvas dimensions exactly (inline-block can shrink-wrap)
|
| 157 |
style={{ position: 'relative', width: containerWidth }}
|
| 158 |
+
className={`rounded-sm overflow-hidden transition-shadow ${
|
| 159 |
+
hasActive ? 'ring-2 ring-signal ring-offset-4 ring-offset-ink-900' : ''
|
| 160 |
}`}
|
| 161 |
>
|
| 162 |
<Page
|
|
|
|
| 185 |
width: `${x1 - x0}%`,
|
| 186 |
height: `${y1 - y0}%`,
|
| 187 |
background: isActive
|
| 188 |
+
? 'rgba(224, 169, 70, 0.30)' /* signal amber */
|
| 189 |
+
: 'rgba(224, 169, 70, 0.10)',
|
| 190 |
border: isActive
|
| 191 |
+
? '2px solid #E0A946'
|
| 192 |
+
: '1px solid rgba(183, 121, 31, 0.55)',
|
| 193 |
borderRadius: 3,
|
| 194 |
boxShadow: isActive
|
| 195 |
+
? '0 0 0 4px rgba(224, 169, 70, 0.22)'
|
| 196 |
: 'none',
|
| 197 |
transition: 'background 0.15s, border 0.15s',
|
| 198 |
}}
|
|
|
|
| 206 |
})}
|
| 207 |
</Document>
|
| 208 |
) : (
|
| 209 |
+
<div className="flex items-center justify-center h-full text-ink-400 text-sm">
|
| 210 |
No PDF selected
|
| 211 |
</div>
|
| 212 |
)}
|
|
|
|
| 217 |
|
| 218 |
function LoadingPlaceholder() {
|
| 219 |
return (
|
| 220 |
+
<div className="flex items-center justify-center p-12 text-ink-400 text-sm font-mono">
|
| 221 |
Loading PDF…
|
| 222 |
</div>
|
| 223 |
)
|
|
|
|
| 225 |
|
| 226 |
function ErrorPlaceholder() {
|
| 227 |
return (
|
| 228 |
+
<div className="flex items-center justify-center p-12 text-[#F2A35E] text-sm font-mono">
|
| 229 |
Failed to load PDF.
|
| 230 |
</div>
|
| 231 |
)
|
ui/src/RecordPane.tsx
CHANGED
|
@@ -32,24 +32,50 @@ export function RecordPane({ sessionId }: Props) {
|
|
| 32 |
|
| 33 |
if (!sessionData) return null
|
| 34 |
|
|
|
|
|
|
|
| 35 |
return (
|
| 36 |
-
<div className="flex flex-col h-full">
|
| 37 |
{/* Header */}
|
| 38 |
-
<div className="px-5
|
| 39 |
-
<
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
</p>
|
| 43 |
</div>
|
| 44 |
|
| 45 |
{/* Scrollable field list */}
|
| 46 |
-
<div className="flex-1 overflow-y-auto px-4 py-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 47 |
{fieldsBySection.map(({ section, entries }) => (
|
| 48 |
<section key={section}>
|
| 49 |
-
<
|
| 50 |
-
{SECTION_LABELS[section] ?? section}
|
| 51 |
-
|
| 52 |
-
|
|
|
|
|
|
|
|
|
|
| 53 |
{entries.map((entry) => (
|
| 54 |
<FieldRow
|
| 55 |
key={entry.fieldPath}
|
|
@@ -158,7 +184,7 @@ function walkObject(
|
|
| 158 |
} else {
|
| 159 |
out.push({
|
| 160 |
fieldPath: path,
|
| 161 |
-
label: formatLabel(key),
|
| 162 |
value: String(val),
|
| 163 |
section: pathPrefix.split('.')[0],
|
| 164 |
provenance: provenanceMap[path],
|
|
@@ -167,8 +193,27 @@ function walkObject(
|
|
| 167 |
}
|
| 168 |
}
|
| 169 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 170 |
function formatLabel(key: string): string {
|
| 171 |
-
return key
|
| 172 |
.replace(/_/g, ' ')
|
| 173 |
.replace(/\b\w/g, (c) => c.toUpperCase())
|
| 174 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 32 |
|
| 33 |
if (!sessionData) return null
|
| 34 |
|
| 35 |
+
const conflicts = sessionData.conflicts ?? []
|
| 36 |
+
|
| 37 |
return (
|
| 38 |
+
<div className="flex flex-col h-full bg-paper">
|
| 39 |
{/* Header */}
|
| 40 |
+
<div className="px-5 h-[52px] flex items-center justify-between gap-3 border-b border-ink-700 bg-ink-800 flex-shrink-0">
|
| 41 |
+
<div className="min-w-0">
|
| 42 |
+
<h2 className="text-[14px] font-semibold text-ink-100 tracking-[-0.01em]">Golden Record</h2>
|
| 43 |
+
</div>
|
| 44 |
+
<p className="text-[12px] text-ink-400 truncate">Select a field to see its source in the PDF</p>
|
| 45 |
</div>
|
| 46 |
|
| 47 |
{/* Scrollable field list */}
|
| 48 |
+
<div className="flex-1 overflow-y-auto scroll-paper px-4 sm:px-5 py-5 space-y-7">
|
| 49 |
+
{conflicts.length > 0 && (
|
| 50 |
+
<section aria-label="Cross-document conflicts" className="rounded-xl border border-signal/50 bg-signal-tint px-4 py-3.5">
|
| 51 |
+
<p className="eyebrow text-signal-deep">
|
| 52 |
+
{conflicts.length} cross-document conflict{conflicts.length > 1 ? 's' : ''} · resolved by authority rules
|
| 53 |
+
</p>
|
| 54 |
+
<ul className="mt-2 space-y-2">
|
| 55 |
+
{conflicts.map((c) => (
|
| 56 |
+
<li key={c.field} className="text-[13px] text-paper-ink">
|
| 57 |
+
<span className="font-mono text-[12px] text-paper-muted">{c.field}</span>
|
| 58 |
+
<span className="block mt-0.5">
|
| 59 |
+
Winner: <span className="font-semibold capitalize">{c.winner}</span>
|
| 60 |
+
{c.schedule_value && c.certificate_value ? (
|
| 61 |
+
<span className="text-paper-muted"> · schedule and certificate wording differ</span>
|
| 62 |
+
) : null}
|
| 63 |
+
</span>
|
| 64 |
+
</li>
|
| 65 |
+
))}
|
| 66 |
+
</ul>
|
| 67 |
+
</section>
|
| 68 |
+
)}
|
| 69 |
+
|
| 70 |
{fieldsBySection.map(({ section, entries }) => (
|
| 71 |
<section key={section}>
|
| 72 |
+
<div className="flex items-baseline justify-between mb-2.5 px-1">
|
| 73 |
+
<h3 className="eyebrow text-signal-deep">{SECTION_LABELS[section] ?? section}</h3>
|
| 74 |
+
<span className="font-mono text-[11px] text-paper-muted">
|
| 75 |
+
{entries.filter((e) => e.provenance).length}/{entries.length} located
|
| 76 |
+
</span>
|
| 77 |
+
</div>
|
| 78 |
+
<div className="space-y-1.5">
|
| 79 |
{entries.map((entry) => (
|
| 80 |
<FieldRow
|
| 81 |
key={entry.fieldPath}
|
|
|
|
| 184 |
} else {
|
| 185 |
out.push({
|
| 186 |
fieldPath: path,
|
| 187 |
+
label: driverPrefix(pathPrefix) + formatLabel(key),
|
| 188 |
value: String(val),
|
| 189 |
section: pathPrefix.split('.')[0],
|
| 190 |
provenance: provenanceMap[path],
|
|
|
|
| 193 |
}
|
| 194 |
}
|
| 195 |
|
| 196 |
+
const LABELS: Record<string, string> = {
|
| 197 |
+
vrm: 'Registration (VRM)',
|
| 198 |
+
dob: 'Date of Birth',
|
| 199 |
+
license_type: 'Licence Type',
|
| 200 |
+
is_main_driver: 'Main Driver',
|
| 201 |
+
has_security_device: 'Security Device',
|
| 202 |
+
overnight_postcode: 'Overnight Postcode',
|
| 203 |
+
standard_compulsory: 'Compulsory Excess',
|
| 204 |
+
total_accidental_damage: 'Accidental Damage Excess',
|
| 205 |
+
own_repairer_additional_excess: 'Own Repairer Excess',
|
| 206 |
+
years: 'NCD Years',
|
| 207 |
+
protected: 'NCD Protected',
|
| 208 |
+
}
|
| 209 |
+
|
| 210 |
function formatLabel(key: string): string {
|
| 211 |
+
return LABELS[key] ?? key
|
| 212 |
.replace(/_/g, ' ')
|
| 213 |
.replace(/\b\w/g, (c) => c.toUpperCase())
|
| 214 |
}
|
| 215 |
+
|
| 216 |
+
function driverPrefix(pathPrefix: string): string {
|
| 217 |
+
const m = pathPrefix.match(/^driver_details\[(\d+)\]$/)
|
| 218 |
+
return m ? `Driver ${Number(m[1]) + 1} · ` : ''
|
| 219 |
+
}
|
ui/src/ReviewDashboard.tsx
CHANGED
|
@@ -1,7 +1,7 @@
|
|
| 1 |
import { PDFPane } from './PDFPane'
|
| 2 |
import { RecordPane } from './RecordPane'
|
| 3 |
import { useStore } from './store'
|
| 4 |
-
import
|
| 5 |
|
| 6 |
interface Props {
|
| 7 |
sessionId: string
|
|
@@ -11,48 +11,50 @@ export function ReviewDashboard({ sessionId }: Props) {
|
|
| 11 |
const sessionData = useStore((s) => s.sessionData)
|
| 12 |
const reviewState = useStore((s) => s.reviewState)
|
| 13 |
|
| 14 |
-
const
|
| 15 |
-
const
|
|
|
|
|
|
|
| 16 |
const provTotal = sessionData?.provenance.length ?? 0
|
| 17 |
const fieldTotal = sessionData ? _countLeaves(sessionData.record) : 0
|
|
|
|
| 18 |
|
| 19 |
return (
|
| 20 |
-
<div className="flex flex-col h-screen overflow-hidden
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
<
|
| 26 |
-
|
| 27 |
-
</a>
|
| 28 |
-
{/* Divider */}
|
| 29 |
-
<span className="text-gray-200 select-none">|</span>
|
| 30 |
-
<div className="flex items-center gap-2">
|
| 31 |
-
<svg width="16" height="16" viewBox="0 0 28 28" fill="none" aria-hidden="true">
|
| 32 |
-
<path d="M4 18L14 22L24 18" stroke="#1F2937" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"/>
|
| 33 |
-
<path d="M4 14L14 18L24 14" stroke="#2563EB" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"/>
|
| 34 |
-
<path d="M4 10L14 14L24 10L14 6L4 10Z" stroke="#008080" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"/>
|
| 35 |
-
</svg>
|
| 36 |
-
<span className="text-sm font-semibold" style={{ color: '#1F2937' }}>PolicyTrace</span>
|
| 37 |
-
</div>
|
| 38 |
-
<span className="text-xs text-gray-400 font-mono bg-gray-50 px-2 py-0.5 rounded-lg border border-gray-200">
|
| 39 |
-
{sessionId.slice(0, 8)}…
|
| 40 |
</span>
|
| 41 |
</div>
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
<
|
| 45 |
-
<Stat label="
|
| 46 |
-
<
|
| 47 |
-
<Stat label="
|
| 48 |
-
<
|
| 49 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 50 |
</div>
|
| 51 |
</header>
|
| 52 |
|
| 53 |
-
{/* ── 2-column body ─────────────────────────────────────────
|
| 54 |
<div className="flex flex-1 overflow-hidden">
|
| 55 |
-
<div className="w-1/2 border-r border-
|
| 56 |
<PDFPane sessionId={sessionId} />
|
| 57 |
</div>
|
| 58 |
<div className="w-1/2 flex flex-col overflow-hidden">
|
|
@@ -63,24 +65,18 @@ export function ReviewDashboard({ sessionId }: Props) {
|
|
| 63 |
)
|
| 64 |
}
|
| 65 |
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
|
|
|
|
|
|
| 69 |
|
| 70 |
-
function Stat({
|
| 71 |
-
label,
|
| 72 |
-
value,
|
| 73 |
-
color = '#374151',
|
| 74 |
-
}: {
|
| 75 |
-
label: string
|
| 76 |
-
value: number
|
| 77 |
-
color?: string
|
| 78 |
-
}) {
|
| 79 |
return (
|
| 80 |
-
<
|
| 81 |
-
{
|
| 82 |
-
<
|
| 83 |
-
</
|
| 84 |
)
|
| 85 |
}
|
| 86 |
|
|
|
|
| 1 |
import { PDFPane } from './PDFPane'
|
| 2 |
import { RecordPane } from './RecordPane'
|
| 3 |
import { useStore } from './store'
|
| 4 |
+
import { ExternalIcon, LINKS, Lockup } from './Brand'
|
| 5 |
|
| 6 |
interface Props {
|
| 7 |
sessionId: string
|
|
|
|
| 11 |
const sessionData = useStore((s) => s.sessionData)
|
| 12 |
const reviewState = useStore((s) => s.reviewState)
|
| 13 |
|
| 14 |
+
const reviews = Object.values(reviewState)
|
| 15 |
+
const verified = reviews.filter((r) => r.action === 'verify').length
|
| 16 |
+
const overridden = reviews.filter((r) => r.action === 'override').length
|
| 17 |
+
const flagged = reviews.filter((r) => r.action === 'reject').length
|
| 18 |
const provTotal = sessionData?.provenance.length ?? 0
|
| 19 |
const fieldTotal = sessionData ? _countLeaves(sessionData.record) : 0
|
| 20 |
+
const reviewed = verified + overridden + flagged
|
| 21 |
|
| 22 |
return (
|
| 23 |
+
<div className="flex flex-col h-screen overflow-hidden bg-ink">
|
| 24 |
+
{/* ── Top bar ─────────────────────────────────────────────── */}
|
| 25 |
+
<header className="flex items-center justify-between gap-4 px-4 sm:px-6 h-14 border-b border-ink-700 flex-shrink-0">
|
| 26 |
+
<div className="flex items-center gap-3 min-w-0">
|
| 27 |
+
<Lockup />
|
| 28 |
+
<span className="hidden md:inline font-mono text-[11px] text-ink-400 border border-ink-600 rounded-md px-2 py-1">
|
| 29 |
+
session {sessionId.slice(0, 8)}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 30 |
</span>
|
| 31 |
</div>
|
| 32 |
+
|
| 33 |
+
<dl className="hidden lg:flex items-center gap-1 font-mono text-[12px]">
|
| 34 |
+
<Stat label="fields" value={fieldTotal} />
|
| 35 |
+
<Stat label="located" value={`${provTotal}`} />
|
| 36 |
+
<Stat label="verified" value={verified} tone="pass" />
|
| 37 |
+
<Stat label="overridden" value={overridden} tone="override" />
|
| 38 |
+
<Stat label="flagged" value={flagged} tone="flag" />
|
| 39 |
+
</dl>
|
| 40 |
+
|
| 41 |
+
<div className="flex items-center gap-3">
|
| 42 |
+
<div className="hidden sm:flex items-center gap-2" aria-label={`${reviewed} of ${fieldTotal} fields reviewed`}>
|
| 43 |
+
<div className="h-1.5 w-28 rounded-full bg-ink-700 overflow-hidden">
|
| 44 |
+
<div className="h-full bg-signal transition-all" style={{ width: `${fieldTotal ? Math.min(100, (reviewed / fieldTotal) * 100) : 0}%` }} />
|
| 45 |
+
</div>
|
| 46 |
+
<span className="font-mono text-[11px] text-ink-400">{reviewed}/{fieldTotal}</span>
|
| 47 |
+
</div>
|
| 48 |
+
<a href={LINKS.chapters} target="_blank" rel="noopener noreferrer"
|
| 49 |
+
className="hidden md:inline-flex items-center gap-1.5 text-[13px] text-ink-300 hover:text-ink-100">
|
| 50 |
+
How it’s built <ExternalIcon />
|
| 51 |
+
</a>
|
| 52 |
</div>
|
| 53 |
</header>
|
| 54 |
|
| 55 |
+
{/* ── 2-column body ───────────────────────────────────────── */}
|
| 56 |
<div className="flex flex-1 overflow-hidden">
|
| 57 |
+
<div className="w-1/2 border-r border-ink-700 flex flex-col overflow-hidden">
|
| 58 |
<PDFPane sessionId={sessionId} />
|
| 59 |
</div>
|
| 60 |
<div className="w-1/2 flex flex-col overflow-hidden">
|
|
|
|
| 65 |
)
|
| 66 |
}
|
| 67 |
|
| 68 |
+
const TONES = {
|
| 69 |
+
pass: 'text-[#6FCF97]',
|
| 70 |
+
override: 'text-[#A9B8E0]',
|
| 71 |
+
flag: 'text-[#F2A35E]',
|
| 72 |
+
} as const
|
| 73 |
|
| 74 |
+
function Stat({ label, value, tone }: { label: string; value: number | string; tone?: keyof typeof TONES }) {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 75 |
return (
|
| 76 |
+
<div className="flex items-baseline gap-1.5 px-2.5 py-1 rounded-md">
|
| 77 |
+
<dd className={`font-medium ${tone ? TONES[tone] : 'text-ink-100'}`}>{value}</dd>
|
| 78 |
+
<dt className="text-ink-400">{label}</dt>
|
| 79 |
+
</div>
|
| 80 |
)
|
| 81 |
}
|
| 82 |
|
ui/src/SessionPage.tsx
CHANGED
|
@@ -44,14 +44,13 @@ export function SessionPage() {
|
|
| 44 |
|
| 45 |
if (loading) {
|
| 46 |
return (
|
| 47 |
-
<div className="min-h-screen flex items-center justify-center
|
| 48 |
<div className="text-center space-y-3">
|
| 49 |
-
<svg className="animate-spin h-8 w-8 mx-auto" viewBox="0 0 24 24" fill="none"
|
| 50 |
-
style={{ color: '#008080' }}>
|
| 51 |
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
| 52 |
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8z" />
|
| 53 |
</svg>
|
| 54 |
-
<p className="text-sm text-
|
| 55 |
</div>
|
| 56 |
</div>
|
| 57 |
)
|
|
@@ -59,15 +58,14 @@ export function SessionPage() {
|
|
| 59 |
|
| 60 |
if (error) {
|
| 61 |
return (
|
| 62 |
-
<div className="min-h-screen flex items-center justify-center
|
| 63 |
<div className="text-center space-y-4 max-w-sm">
|
| 64 |
-
<p className="text-sm text-
|
| 65 |
{error}
|
| 66 |
</p>
|
| 67 |
<button
|
| 68 |
onClick={() => navigate('/')}
|
| 69 |
-
className="text-sm font-medium
|
| 70 |
-
style={{ color: '#2563EB' }}
|
| 71 |
>
|
| 72 |
← Back to upload
|
| 73 |
</button>
|
|
|
|
| 44 |
|
| 45 |
if (loading) {
|
| 46 |
return (
|
| 47 |
+
<div className="min-h-screen flex items-center justify-center bg-ink grid-bg">
|
| 48 |
<div className="text-center space-y-3">
|
| 49 |
+
<svg className="animate-spin h-8 w-8 mx-auto text-signal" viewBox="0 0 24 24" fill="none">
|
|
|
|
| 50 |
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
| 51 |
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8z" />
|
| 52 |
</svg>
|
| 53 |
+
<p className="font-mono text-sm text-ink-300">Loading session…</p>
|
| 54 |
</div>
|
| 55 |
</div>
|
| 56 |
)
|
|
|
|
| 58 |
|
| 59 |
if (error) {
|
| 60 |
return (
|
| 61 |
+
<div className="min-h-screen flex items-center justify-center bg-ink grid-bg">
|
| 62 |
<div className="text-center space-y-4 max-w-sm">
|
| 63 |
+
<p role="alert" className="text-sm text-[#F2B79A] bg-state-flag/10 border border-state-flag/40 rounded-xl px-4 py-3">
|
| 64 |
{error}
|
| 65 |
</p>
|
| 66 |
<button
|
| 67 |
onClick={() => navigate('/')}
|
| 68 |
+
className="text-sm font-medium text-signal hover:text-signal-hover"
|
|
|
|
| 69 |
>
|
| 70 |
← Back to upload
|
| 71 |
</button>
|
ui/src/UploadPage.tsx
CHANGED
|
@@ -1,22 +1,27 @@
|
|
| 1 |
-
import { useCallback, useEffect, useState } from 'react'
|
| 2 |
import { useNavigate } from 'react-router-dom'
|
| 3 |
import { api } from './api'
|
| 4 |
import { useStore } from './store'
|
| 5 |
import type { SamplePack } from './types'
|
| 6 |
-
import
|
| 7 |
-
|
| 8 |
-
const
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
|
|
|
|
|
|
|
|
|
| 14 |
|
| 15 |
export function UploadPage() {
|
| 16 |
const navigate = useNavigate()
|
| 17 |
const setSession = useStore((s) => s.setSession)
|
|
|
|
| 18 |
|
| 19 |
const [loading, setLoading] = useState(false)
|
|
|
|
| 20 |
const [error, setError] = useState<string | null>(null)
|
| 21 |
const [files, setFiles] = useState<File[]>([])
|
| 22 |
const [dragOver, setDragOver] = useState(false)
|
|
@@ -26,17 +31,19 @@ export function UploadPage() {
|
|
| 26 |
useEffect(() => {
|
| 27 |
let isMounted = true
|
| 28 |
api.getSamples()
|
| 29 |
-
.then((pack) => {
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
.catch(() => {
|
| 33 |
-
if (isMounted) setSamplePack(null)
|
| 34 |
-
})
|
| 35 |
-
return () => {
|
| 36 |
-
isMounted = false
|
| 37 |
-
}
|
| 38 |
}, [])
|
| 39 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 40 |
const addPdfFiles = useCallback((incoming: File[]) => {
|
| 41 |
const pdfs = incoming.filter((f) => f.name.toLowerCase().endsWith('.pdf'))
|
| 42 |
setFiles((prev) => {
|
|
@@ -50,21 +57,17 @@ export function UploadPage() {
|
|
| 50 |
addPdfFiles(Array.from(incoming))
|
| 51 |
}, [addPdfFiles])
|
| 52 |
|
| 53 |
-
const removeFile = (name: string) =>
|
| 54 |
-
setFiles((prev) => prev.filter((f) => f.name !== name))
|
| 55 |
|
| 56 |
const handleLoadSamples = async () => {
|
| 57 |
if (!samplePack?.files.length) return
|
| 58 |
setLoadingSamples(true)
|
| 59 |
setError(null)
|
| 60 |
try {
|
| 61 |
-
const demoFiles = await Promise.all(
|
| 62 |
-
samplePack.files.map((sample) => api.downloadSampleFile(sample)),
|
| 63 |
-
)
|
| 64 |
addPdfFiles(demoFiles)
|
| 65 |
} catch (err: unknown) {
|
| 66 |
-
|
| 67 |
-
setError(msg)
|
| 68 |
} finally {
|
| 69 |
setLoadingSamples(false)
|
| 70 |
}
|
|
@@ -80,214 +83,142 @@ export function UploadPage() {
|
|
| 80 |
setSession(sessionData)
|
| 81 |
navigate(`/session/${resp.session_id}`)
|
| 82 |
} catch (err: unknown) {
|
| 83 |
-
|
| 84 |
-
setError(msg)
|
| 85 |
setLoading(false)
|
| 86 |
}
|
| 87 |
}
|
| 88 |
|
| 89 |
return (
|
| 90 |
-
<div className="min-h-screen flex flex-col
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
<
|
| 95 |
-
<
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
|
|
|
| 103 |
</header>
|
| 104 |
|
| 105 |
-
{/* ──
|
| 106 |
-
<main className="flex-1
|
| 107 |
-
<div className="
|
| 108 |
-
|
| 109 |
-
{/*
|
| 110 |
-
<
|
| 111 |
-
<
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
</h1>
|
| 120 |
-
</div>
|
| 121 |
-
<p className="text-sm text-gray-500 leading-relaxed">
|
| 122 |
-
Upload UK motor insurance PDFs — the pipeline classifies, extracts, and merges
|
| 123 |
-
them into a verified Golden Record with full field-level provenance.
|
| 124 |
</p>
|
| 125 |
-
</div>
|
| 126 |
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 149 |
>
|
| 150 |
-
<
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
<
|
| 161 |
-
id="file-input"
|
| 162 |
-
type="file"
|
| 163 |
-
accept=".pdf"
|
| 164 |
-
multiple
|
| 165 |
-
className="hidden"
|
| 166 |
-
onChange={(e) => handleFiles(e.target.files)}
|
| 167 |
-
/>
|
| 168 |
-
</div>
|
| 169 |
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
<p className="mt-1 text-xs leading-relaxed text-gray-500">
|
| 178 |
-
Safe sample PDFs are bundled with the Hugging Face Space. Load them here,
|
| 179 |
-
then run the same upload and review flow.
|
| 180 |
-
</p>
|
| 181 |
-
{(samplePack.expected_policy_number || samplePack.expected_vrm) && (
|
| 182 |
-
<p className="mt-2 text-xs font-medium" style={{ color: BRAND.teal }}>
|
| 183 |
-
Expected: {samplePack.expected_policy_number}
|
| 184 |
-
{samplePack.expected_vrm ? ` / ${samplePack.expected_vrm}` : ''}
|
| 185 |
</p>
|
| 186 |
-
|
|
|
|
|
|
|
|
|
|
| 187 |
</div>
|
| 188 |
-
<button
|
| 189 |
-
type="button"
|
| 190 |
-
onClick={handleLoadSamples}
|
| 191 |
-
disabled={loadingSamples}
|
| 192 |
-
className="shrink-0 rounded-xl px-4 py-2 text-xs font-semibold text-white transition-colors disabled:opacity-50"
|
| 193 |
-
style={{ backgroundColor: BRAND.teal }}
|
| 194 |
-
>
|
| 195 |
-
{loadingSamples ? 'Loading samples...' : 'Load demo PDFs'}
|
| 196 |
-
</button>
|
| 197 |
</div>
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
{
|
| 209 |
-
|
|
|
|
|
|
|
|
|
|
| 210 |
))}
|
| 211 |
-
</
|
| 212 |
-
|
| 213 |
-
) : null}
|
| 214 |
-
|
| 215 |
-
{/* File list */}
|
| 216 |
-
{files.length > 0 && (
|
| 217 |
-
<ul className="mt-4 space-y-2">
|
| 218 |
-
{files.map((f) => (
|
| 219 |
-
<li
|
| 220 |
-
key={f.name}
|
| 221 |
-
className="flex items-center justify-between bg-white border border-gray-200 rounded-xl px-4 py-2.5 text-sm shadow-sm"
|
| 222 |
-
>
|
| 223 |
-
<div className="flex items-center gap-2 min-w-0">
|
| 224 |
-
<span
|
| 225 |
-
className="shrink-0 text-xs font-semibold px-1.5 py-0.5 rounded"
|
| 226 |
-
style={{ backgroundColor: '#fee2e2', color: '#991b1b' }}
|
| 227 |
-
>
|
| 228 |
-
PDF
|
| 229 |
-
</span>
|
| 230 |
-
<span className="text-gray-700 truncate">{f.name}</span>
|
| 231 |
-
</div>
|
| 232 |
-
<button
|
| 233 |
-
onClick={() => removeFile(f.name)}
|
| 234 |
-
className="text-gray-300 hover:text-red-500 ml-3 shrink-0 transition-colors"
|
| 235 |
-
aria-label={`Remove ${f.name}`}
|
| 236 |
-
>
|
| 237 |
-
✕
|
| 238 |
-
</button>
|
| 239 |
-
</li>
|
| 240 |
-
))}
|
| 241 |
-
</ul>
|
| 242 |
-
)}
|
| 243 |
-
|
| 244 |
-
{/* Error */}
|
| 245 |
-
{error && (
|
| 246 |
-
<div className="mt-4 rounded-xl bg-red-50 border border-red-200 p-3 text-sm text-red-700">
|
| 247 |
-
{error}
|
| 248 |
-
</div>
|
| 249 |
-
)}
|
| 250 |
|
| 251 |
-
|
| 252 |
-
|
| 253 |
-
|
| 254 |
-
|
| 255 |
-
className="mt-6 w-full py-3 px-6 rounded-xl font-semibold text-white transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
| 256 |
-
style={{ backgroundColor: loading ? BRAND.teal : BRAND.blue }}
|
| 257 |
-
>
|
| 258 |
-
{loading ? (
|
| 259 |
-
<span className="flex items-center justify-center gap-2">
|
| 260 |
-
<svg className="animate-spin h-4 w-4" viewBox="0 0 24 24" fill="none">
|
| 261 |
-
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
| 262 |
-
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8z" />
|
| 263 |
-
</svg>
|
| 264 |
-
Extracting — this may take 60 s…
|
| 265 |
-
</span>
|
| 266 |
-
) : (
|
| 267 |
-
'Extract & Review'
|
| 268 |
)}
|
| 269 |
-
</button>
|
| 270 |
|
| 271 |
-
|
| 272 |
-
|
| 273 |
-
|
|
|
|
|
|
|
|
|
|
| 274 |
</p>
|
| 275 |
-
|
| 276 |
</div>
|
| 277 |
</main>
|
| 278 |
|
| 279 |
-
|
| 280 |
-
|
| 281 |
-
|
| 282 |
-
<a
|
| 283 |
-
href="https://www.ai-toolstack.com/"
|
| 284 |
-
target="_blank"
|
| 285 |
-
rel="noopener noreferrer"
|
| 286 |
-
className="underline hover:text-gray-600 transition-colors"
|
| 287 |
-
>
|
| 288 |
-
AI Tool Stack
|
| 289 |
-
</a>{' '}
|
| 290 |
-
· Powered by Groq & Docling
|
| 291 |
</footer>
|
| 292 |
</div>
|
| 293 |
)
|
|
|
|
| 1 |
+
import { useCallback, useEffect, useRef, useState } from 'react'
|
| 2 |
import { useNavigate } from 'react-router-dom'
|
| 3 |
import { api } from './api'
|
| 4 |
import { useStore } from './store'
|
| 5 |
import type { SamplePack } from './types'
|
| 6 |
+
import { ExternalIcon, LINKS, Lockup, Spinner } from './Brand'
|
| 7 |
+
|
| 8 |
+
const PIPELINE = [
|
| 9 |
+
['01', 'parse', 'Docling layout + text'],
|
| 10 |
+
['02', 'pii_mask', 'Presidio, before any model call'],
|
| 11 |
+
['03', 'classify', 'route each document'],
|
| 12 |
+
['04', 'extract', 'typed record + verbatim citations'],
|
| 13 |
+
['05', 'arbitrate', 'source-authority rules'],
|
| 14 |
+
['06', 'provenance', 'match citations to PDF geometry'],
|
| 15 |
+
['07', 'review', 'you verify, flag or override'],
|
| 16 |
+
] as const
|
| 17 |
|
| 18 |
export function UploadPage() {
|
| 19 |
const navigate = useNavigate()
|
| 20 |
const setSession = useStore((s) => s.setSession)
|
| 21 |
+
const inputRef = useRef<HTMLInputElement>(null)
|
| 22 |
|
| 23 |
const [loading, setLoading] = useState(false)
|
| 24 |
+
const [step, setStep] = useState(0)
|
| 25 |
const [error, setError] = useState<string | null>(null)
|
| 26 |
const [files, setFiles] = useState<File[]>([])
|
| 27 |
const [dragOver, setDragOver] = useState(false)
|
|
|
|
| 31 |
useEffect(() => {
|
| 32 |
let isMounted = true
|
| 33 |
api.getSamples()
|
| 34 |
+
.then((pack) => { if (isMounted) setSamplePack(pack) })
|
| 35 |
+
.catch(() => { if (isMounted) setSamplePack(null) })
|
| 36 |
+
return () => { isMounted = false }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 37 |
}, [])
|
| 38 |
|
| 39 |
+
// Advance the visual pipeline while the (synchronous) request runs.
|
| 40 |
+
useEffect(() => {
|
| 41 |
+
if (!loading) return
|
| 42 |
+
setStep(0)
|
| 43 |
+
const t = setInterval(() => setStep((s) => Math.min(s + 1, PIPELINE.length - 2)), 6000)
|
| 44 |
+
return () => clearInterval(t)
|
| 45 |
+
}, [loading])
|
| 46 |
+
|
| 47 |
const addPdfFiles = useCallback((incoming: File[]) => {
|
| 48 |
const pdfs = incoming.filter((f) => f.name.toLowerCase().endsWith('.pdf'))
|
| 49 |
setFiles((prev) => {
|
|
|
|
| 57 |
addPdfFiles(Array.from(incoming))
|
| 58 |
}, [addPdfFiles])
|
| 59 |
|
| 60 |
+
const removeFile = (name: string) => setFiles((prev) => prev.filter((f) => f.name !== name))
|
|
|
|
| 61 |
|
| 62 |
const handleLoadSamples = async () => {
|
| 63 |
if (!samplePack?.files.length) return
|
| 64 |
setLoadingSamples(true)
|
| 65 |
setError(null)
|
| 66 |
try {
|
| 67 |
+
const demoFiles = await Promise.all(samplePack.files.map((sample) => api.downloadSampleFile(sample)))
|
|
|
|
|
|
|
| 68 |
addPdfFiles(demoFiles)
|
| 69 |
} catch (err: unknown) {
|
| 70 |
+
setError(err instanceof Error ? err.message : 'Could not load the demo sample files.')
|
|
|
|
| 71 |
} finally {
|
| 72 |
setLoadingSamples(false)
|
| 73 |
}
|
|
|
|
| 83 |
setSession(sessionData)
|
| 84 |
navigate(`/session/${resp.session_id}`)
|
| 85 |
} catch (err: unknown) {
|
| 86 |
+
setError(err instanceof Error ? err.message : 'An unknown error occurred.')
|
|
|
|
| 87 |
setLoading(false)
|
| 88 |
}
|
| 89 |
}
|
| 90 |
|
| 91 |
return (
|
| 92 |
+
<div className="min-h-screen flex flex-col bg-ink">
|
| 93 |
+
{/* ── Top bar ─────────────────────────────────────────────── */}
|
| 94 |
+
<header className="flex items-center justify-between gap-4 px-5 sm:px-8 h-16 border-b border-ink-700">
|
| 95 |
+
<Lockup />
|
| 96 |
+
<nav className="flex items-center gap-1 text-sm">
|
| 97 |
+
<a href={LINKS.chapters} target="_blank" rel="noopener noreferrer"
|
| 98 |
+
className="hidden sm:inline-flex items-center gap-1.5 px-3 py-2 rounded-lg text-ink-300 hover:text-ink-100 hover:bg-ink-800">
|
| 99 |
+
How it’s built <ExternalIcon />
|
| 100 |
+
</a>
|
| 101 |
+
<a href={LINKS.github} target="_blank" rel="noopener noreferrer"
|
| 102 |
+
className="inline-flex items-center gap-1.5 px-3 py-2 rounded-lg text-ink-300 hover:text-ink-100 hover:bg-ink-800">
|
| 103 |
+
Source <ExternalIcon />
|
| 104 |
+
</a>
|
| 105 |
+
</nav>
|
| 106 |
</header>
|
| 107 |
|
| 108 |
+
{/* ── Body ────────────────────────────────────────────────── */}
|
| 109 |
+
<main className="flex-1 grid-bg">
|
| 110 |
+
<div className="mx-auto max-w-6xl px-5 sm:px-8 py-12 lg:py-20 grid gap-10 lg:gap-16 lg:grid-cols-[1.05fr_1fr] items-start">
|
| 111 |
+
|
| 112 |
+
{/* Left: what this is */}
|
| 113 |
+
<section className="space-y-7 lg:pt-4">
|
| 114 |
+
<p className="eyebrow text-signal">Reference system · Document AI</p>
|
| 115 |
+
<h1 className="text-[40px] sm:text-[52px] leading-[1.0] font-semibold tracking-[-0.045em] text-ink-100">
|
| 116 |
+
Reviewable extraction for motor insurance packs.
|
| 117 |
+
</h1>
|
| 118 |
+
<p className="text-[17px] leading-relaxed text-ink-300 max-w-[34rem]">
|
| 119 |
+
Upload a Schedule, Certificate, Statement of Fact and Policy Booklet. PolicyTrace builds one typed
|
| 120 |
+
Golden Record, resolves disagreements between documents, and links every field back to the exact
|
| 121 |
+
place it came from in the PDF.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 122 |
</p>
|
|
|
|
| 123 |
|
| 124 |
+
<ol className="rounded-xl border border-ink-600 bg-ink-800/90 font-mono text-[13px] overflow-hidden" aria-label="Pipeline">
|
| 125 |
+
{PIPELINE.map(([n, name, desc], i) => {
|
| 126 |
+
const state = !loading ? 'idle' : i < step ? 'done' : i === step ? 'active' : 'idle'
|
| 127 |
+
return (
|
| 128 |
+
<li key={n} className="flex items-center gap-4 px-4 py-2.5 border-b border-dashed border-ink-700 last:border-b-0">
|
| 129 |
+
<span className="w-6 text-ink-400">{n}</span>
|
| 130 |
+
<span className={`w-24 ${state === 'active' ? 'text-signal' : 'text-ink-100'}`}>{name}</span>
|
| 131 |
+
<span className="flex-1 text-ink-400 truncate">{desc}</span>
|
| 132 |
+
<span className="w-14 text-right">
|
| 133 |
+
{state === 'done' && <span className="text-[#6FCF97]">ok</span>}
|
| 134 |
+
{state === 'active' && <span className="inline-flex text-signal"><Spinner className="h-3.5 w-3.5" /></span>}
|
| 135 |
+
</span>
|
| 136 |
+
</li>
|
| 137 |
+
)
|
| 138 |
+
})}
|
| 139 |
+
</ol>
|
| 140 |
+
</section>
|
| 141 |
+
|
| 142 |
+
{/* Right: upload card */}
|
| 143 |
+
<section className="rounded-2xl border border-ink-600 bg-ink-800 p-5 sm:p-6 shadow-page" aria-label="Upload documents">
|
| 144 |
+
<div
|
| 145 |
+
role="button"
|
| 146 |
+
tabIndex={0}
|
| 147 |
+
aria-label="Add PDF files"
|
| 148 |
+
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') inputRef.current?.click() }}
|
| 149 |
+
onDragOver={(e) => { e.preventDefault(); setDragOver(true) }}
|
| 150 |
+
onDragLeave={() => setDragOver(false)}
|
| 151 |
+
onDrop={(e) => { e.preventDefault(); setDragOver(false); handleFiles(e.dataTransfer.files) }}
|
| 152 |
+
onClick={() => inputRef.current?.click()}
|
| 153 |
+
className={`rounded-xl border border-dashed px-6 py-10 text-center cursor-pointer transition-colors ${
|
| 154 |
+
dragOver ? 'border-signal bg-signal/10' : 'border-ink-500 hover:border-ink-400 bg-ink-900/40'
|
| 155 |
+
}`}
|
| 156 |
>
|
| 157 |
+
<svg className={`mx-auto mb-4 h-9 w-9 ${dragOver ? 'text-signal' : 'text-ink-400'}`} fill="none" viewBox="0 0 24 24"
|
| 158 |
+
stroke="currentColor" strokeWidth={1.5} aria-hidden="true">
|
| 159 |
+
<path strokeLinecap="round" strokeLinejoin="round" d="M12 16V4m0 0-4 4m4-4 4 4M4 16v2a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-2" />
|
| 160 |
+
</svg>
|
| 161 |
+
<p className="text-[15px] font-medium text-ink-100">
|
| 162 |
+
Drop PDFs here or <span className="text-signal">browse</span>
|
| 163 |
+
</p>
|
| 164 |
+
<p className="mt-1.5 font-mono text-[12px] text-ink-400">Schedule · Certificate · Statement of Fact · Booklet</p>
|
| 165 |
+
<input ref={inputRef} type="file" accept=".pdf" multiple className="hidden"
|
| 166 |
+
onChange={(e) => handleFiles(e.target.files)} />
|
| 167 |
+
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 168 |
|
| 169 |
+
{samplePack?.files.length ? (
|
| 170 |
+
<div className="mt-4 rounded-xl border border-ink-600 bg-ink-900/50 p-4">
|
| 171 |
+
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
| 172 |
+
<div>
|
| 173 |
+
<p className="text-sm font-medium text-ink-100">No documents to hand?</p>
|
| 174 |
+
<p className="mt-0.5 text-[13px] text-ink-300">
|
| 175 |
+
Load the synthetic demo pack{samplePack.expected_policy_number ? <> (<span className="font-mono">{samplePack.expected_policy_number}</span>)</> : null}. No real customer data.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 176 |
</p>
|
| 177 |
+
</div>
|
| 178 |
+
<button type="button" onClick={handleLoadSamples} disabled={loadingSamples} className="btn-ghost text-sm shrink-0">
|
| 179 |
+
{loadingSamples ? <><Spinner /> Loading…</> : 'Load demo pack'}
|
| 180 |
+
</button>
|
| 181 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 182 |
</div>
|
| 183 |
+
) : null}
|
| 184 |
+
|
| 185 |
+
{files.length > 0 && (
|
| 186 |
+
<ul className="mt-4 space-y-2" aria-label="Selected files">
|
| 187 |
+
{files.map((f) => (
|
| 188 |
+
<li key={f.name} className="flex items-center justify-between gap-3 rounded-lg border border-ink-600 bg-ink-900/40 px-3.5 py-2.5">
|
| 189 |
+
<div className="flex items-center gap-3 min-w-0">
|
| 190 |
+
<span className="shrink-0 rounded font-mono text-[10px] font-medium px-1.5 py-0.5 bg-ink-700 text-ink-300">PDF</span>
|
| 191 |
+
<span className="text-sm text-ink-200 truncate">{f.name}</span>
|
| 192 |
+
</div>
|
| 193 |
+
<button onClick={() => removeFile(f.name)} aria-label={`Remove ${f.name}`}
|
| 194 |
+
className="shrink-0 h-8 w-8 grid place-items-center rounded-md text-ink-400 hover:text-ink-100 hover:bg-ink-700">
|
| 195 |
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" aria-hidden="true"><path d="M6 6l12 12M18 6 6 18" /></svg>
|
| 196 |
+
</button>
|
| 197 |
+
</li>
|
| 198 |
))}
|
| 199 |
+
</ul>
|
| 200 |
+
)}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 201 |
|
| 202 |
+
{error && (
|
| 203 |
+
<div role="alert" className="mt-4 rounded-lg border border-state-flag/40 bg-state-flag/10 px-3.5 py-3 text-sm text-[#F2B79A]">
|
| 204 |
+
{error}
|
| 205 |
+
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 206 |
)}
|
|
|
|
| 207 |
|
| 208 |
+
<button onClick={handleSubmit} disabled={!files.length || loading} className="btn-signal w-full mt-5 text-[15px]">
|
| 209 |
+
{loading ? <><Spinner /> Extracting · usually 30–90 s</> : <>Extract & review{files.length ? ` ${files.length} file${files.length > 1 ? 's' : ''}` : ''}</>}
|
| 210 |
+
</button>
|
| 211 |
+
|
| 212 |
+
<p className="mt-4 text-[12px] leading-relaxed text-ink-400">
|
| 213 |
+
Public demo. Use synthetic or redacted documents only.
|
| 214 |
</p>
|
| 215 |
+
</section>
|
| 216 |
</div>
|
| 217 |
</main>
|
| 218 |
|
| 219 |
+
<footer className="border-t border-ink-700 px-5 sm:px-8 py-4 flex flex-wrap gap-x-6 gap-y-1 justify-between font-mono text-[12px] text-ink-400">
|
| 220 |
+
<span>PolicyTrace · a reference system from <a className="text-ink-300 hover:text-signal" href="https://www.ai-toolstack.com/" target="_blank" rel="noopener noreferrer">ai·toolstack</a></span>
|
| 221 |
+
<span>Docling · Presidio · Groq · Instructor · FastAPI · React</span>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 222 |
</footer>
|
| 223 |
</div>
|
| 224 |
)
|
ui/src/index.css
CHANGED
|
@@ -2,30 +2,59 @@
|
|
| 2 |
@tailwind components;
|
| 3 |
@tailwind utilities;
|
| 4 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
@layer base {
|
|
|
|
| 6 |
body {
|
| 7 |
-
@apply bg-
|
| 8 |
-
|
| 9 |
}
|
|
|
|
|
|
|
| 10 |
}
|
| 11 |
|
| 12 |
-
|
| 13 |
-
.
|
| 14 |
-
|
| 15 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 16 |
}
|
| 17 |
-
.btn-primary:hover { background-color: #1d4ed8; }
|
| 18 |
-
.btn-primary:disabled { @apply opacity-50 cursor-not-allowed; }
|
| 19 |
|
| 20 |
/* ── react-pdf ──────────────────────────────────────────────────── */
|
| 21 |
-
.react-pdf__Page {
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
.
|
| 25 |
-
|
| 26 |
-
|
|
|
|
|
|
|
| 27 |
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
scroll-behavior: smooth;
|
| 31 |
}
|
|
|
|
| 2 |
@tailwind components;
|
| 3 |
@tailwind utilities;
|
| 4 |
|
| 5 |
+
/* Geist 1.8.1 (SIL OFL) — same files as ai-toolstack.com */
|
| 6 |
+
@font-face {
|
| 7 |
+
font-family: "Geist";
|
| 8 |
+
src: url("https://cdn.jsdelivr.net/npm/geist@1.8.1/dist/fonts/geist-sans/Geist-Variable.woff2") format("woff2");
|
| 9 |
+
font-weight: 100 900;
|
| 10 |
+
font-display: swap;
|
| 11 |
+
}
|
| 12 |
+
@font-face {
|
| 13 |
+
font-family: "Geist Mono";
|
| 14 |
+
src: url("https://cdn.jsdelivr.net/npm/geist@1.8.1/dist/fonts/geist-mono/GeistMono-Variable.woff2") format("woff2");
|
| 15 |
+
font-weight: 100 900;
|
| 16 |
+
font-display: swap;
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
@layer base {
|
| 20 |
+
html { -webkit-text-size-adjust: 100%; }
|
| 21 |
body {
|
| 22 |
+
@apply bg-ink text-ink-100 font-sans antialiased;
|
| 23 |
+
text-rendering: optimizeLegibility;
|
| 24 |
}
|
| 25 |
+
::selection { background: rgba(224, 169, 70, 0.35); }
|
| 26 |
+
:focus-visible { outline: 2px solid #E0A946; outline-offset: 2px; border-radius: 6px; }
|
| 27 |
}
|
| 28 |
|
| 29 |
+
@layer components {
|
| 30 |
+
.eyebrow {
|
| 31 |
+
@apply font-mono text-[11px] font-medium uppercase tracking-[0.12em];
|
| 32 |
+
}
|
| 33 |
+
.btn {
|
| 34 |
+
@apply inline-flex items-center justify-center gap-2 rounded-lg px-4 font-medium transition-colors
|
| 35 |
+
disabled:opacity-50 disabled:cursor-not-allowed;
|
| 36 |
+
min-height: 44px;
|
| 37 |
+
}
|
| 38 |
+
.btn-signal { @apply btn bg-signal text-ink hover:bg-signal-hover; }
|
| 39 |
+
.btn-ghost { @apply btn border border-ink-600 text-ink-100 hover:bg-ink-800; }
|
| 40 |
+
.grid-bg {
|
| 41 |
+
background-image:
|
| 42 |
+
linear-gradient(rgba(44, 55, 82, 0.35) 1px, transparent 1px),
|
| 43 |
+
linear-gradient(90deg, rgba(44, 55, 82, 0.35) 1px, transparent 1px);
|
| 44 |
+
background-size: 56px 56px;
|
| 45 |
+
}
|
| 46 |
}
|
|
|
|
|
|
|
| 47 |
|
| 48 |
/* ── react-pdf ──────────────────────────────────────────────────── */
|
| 49 |
+
.react-pdf__Page { @apply shadow-page; }
|
| 50 |
+
.react-pdf__Page__canvas { @apply block; }
|
| 51 |
+
|
| 52 |
+
.pdf-scroll-container { scroll-behavior: smooth; }
|
| 53 |
+
|
| 54 |
+
/* Thin scrollbars that suit both panes */
|
| 55 |
+
.scroll-ink { scrollbar-color: #2C3752 transparent; scrollbar-width: thin; }
|
| 56 |
+
.scroll-paper { scrollbar-color: #D3D0C6 transparent; scrollbar-width: thin; }
|
| 57 |
|
| 58 |
+
@media (prefers-reduced-motion: reduce) {
|
| 59 |
+
*, *::before, *::after { transition: none !important; animation: none !important; scroll-behavior: auto !important; }
|
|
|
|
| 60 |
}
|
ui/tailwind.config.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
| 1 |
/** @type {import('tailwindcss').Config} */
|
|
|
|
| 2 |
export default {
|
| 3 |
content: [
|
| 4 |
"./index.html",
|
|
@@ -7,18 +8,47 @@ export default {
|
|
| 7 |
theme: {
|
| 8 |
extend: {
|
| 9 |
colors: {
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
},
|
| 19 |
},
|
| 20 |
fontFamily: {
|
| 21 |
-
sans: ['
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 22 |
},
|
| 23 |
},
|
| 24 |
},
|
|
|
|
| 1 |
/** @type {import('tailwindcss').Config} */
|
| 2 |
+
// ai·toolstack design tokens (mirrors the ai-toolstack.com theme)
|
| 3 |
export default {
|
| 4 |
content: [
|
| 5 |
"./index.html",
|
|
|
|
| 8 |
theme: {
|
| 9 |
extend: {
|
| 10 |
colors: {
|
| 11 |
+
ink: {
|
| 12 |
+
DEFAULT: '#121826', // midnight slate ground
|
| 13 |
+
900: '#121826',
|
| 14 |
+
800: '#182033', // surface
|
| 15 |
+
700: '#1F2940', // raised
|
| 16 |
+
600: '#2C3752', // rule
|
| 17 |
+
500: '#3A4766',
|
| 18 |
+
400: '#7E88A2', // dim text (AA on ink)
|
| 19 |
+
300: '#9CA6BA', // muted text
|
| 20 |
+
200: '#CDD3DE', // body text on ink
|
| 21 |
+
100: '#ECEAE4', // bone
|
| 22 |
+
},
|
| 23 |
+
paper: {
|
| 24 |
+
DEFAULT: '#F6F5F1',
|
| 25 |
+
sheet: '#FFFFFF',
|
| 26 |
+
rule: '#E2E0D9',
|
| 27 |
+
muted: '#5A6478',
|
| 28 |
+
ink: '#2B3245',
|
| 29 |
+
},
|
| 30 |
+
signal: {
|
| 31 |
+
DEFAULT: '#E0A946', // amber accent
|
| 32 |
+
hover: '#ECBE6A',
|
| 33 |
+
deep: '#8F5A0E', // amber for text on paper (AA)
|
| 34 |
+
tint: '#FBF3E4',
|
| 35 |
+
},
|
| 36 |
+
state: {
|
| 37 |
+
pass: '#1F7A4D',
|
| 38 |
+
'pass-tint': '#EDF6F0',
|
| 39 |
+
override: '#3B4A6B',
|
| 40 |
+
'override-tint': '#EEF1F6',
|
| 41 |
+
flag: '#B4532A',
|
| 42 |
+
'flag-tint': '#F8EDE7',
|
| 43 |
},
|
| 44 |
},
|
| 45 |
fontFamily: {
|
| 46 |
+
sans: ['Geist', 'ui-sans-serif', 'system-ui', '-apple-system', 'Segoe UI', 'sans-serif'],
|
| 47 |
+
mono: ['"Geist Mono"', 'ui-monospace', 'SFMono-Regular', 'Menlo', 'Consolas', 'monospace'],
|
| 48 |
+
},
|
| 49 |
+
boxShadow: {
|
| 50 |
+
sheet: '0 1px 0 rgba(18,24,38,0.04), 0 8px 24px -16px rgba(18,24,38,0.25)',
|
| 51 |
+
page: '0 24px 60px -30px rgba(0,0,0,0.65)',
|
| 52 |
},
|
| 53 |
},
|
| 54 |
},
|