AItoolstack commited on
Commit
140c7b5
·
verified ·
1 Parent(s): 4579742

Rebrand review UI and move to Groq gpt-oss models

Browse files
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: "meta-llama/llama-4-scout-17b-16e-instruct"
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
- llm_result = response.choices[0].message.content.strip().split()[0]
 
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="/vite.svg" />
6
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
7
- <title>PolicyTrace — Motor Insurance IDP · AI Tool Stack</title>
 
 
 
 
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 { type CSSProperties, useState } from 'react'
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 = review?.action === 'verify'
25
- const isRejected = review?.action === 'reject'
26
  const isOverridden = review?.action === 'override'
27
 
28
- const borderStyle: CSSProperties = isVerified
29
- ? { borderColor: '#16a34a', backgroundColor: '#f0fdf4' }
30
  : isRejected
31
- ? { borderColor: '#fca5a5', backgroundColor: '#fef2f2' }
32
  : isOverridden
33
- ? { borderColor: '#2563EB', backgroundColor: '#eff6ff' }
34
  : isActive
35
- ? { borderColor: '#008080', backgroundColor: '#f0fdfc' }
36
- : { borderColor: 'transparent', backgroundColor: '#ffffff' }
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="rounded-lg border px-3 py-2 cursor-pointer transition-all hover:shadow-sm"
46
- style={borderStyle}
47
  onClick={onClick}
 
 
 
 
48
  >
49
- <div className="flex items-start gap-2">
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-xs font-semibold text-gray-500 shrink-0">
54
- {entry.label}
55
- </span>
56
- {isVerified && (
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="flex gap-2 mt-1"
73
- onClick={(e) => e.stopPropagation()}
74
- >
75
  <input
 
76
  autoFocus
77
- className="flex-1 text-xs border rounded px-2 py-1 focus:outline-none focus:ring-1 focus:ring-blue-400"
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
- onClick={handleSaveOverride}
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-sm text-gray-800 mt-0.5 truncate">
100
- {displayValue ?? (
101
- <span className="text-gray-300 italic">Not extracted</span>
102
- )}
103
  </p>
104
  )}
105
 
106
- {/* Provenance source hint — or explicit "no location" notice */}
107
  {!editing && (
108
  entry.provenance ? (
109
- <p className="text-xs text-gray-400 mt-0.5 truncate">
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-xs mt-0.5">
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
- {/* Right side: confidence badge + action buttons */}
124
- <div
125
- className="flex items-center gap-1 flex-shrink-0"
126
- onClick={(e) => e.stopPropagation()}
127
- >
128
- {entry.provenance && (
129
- <ConfidenceBadge score={entry.provenance.match_score} />
130
- )}
131
-
132
- {/* Verify */}
133
- <button
134
- title="Mark as verified"
135
- onClick={() => verifyField(sessionId, entry.fieldPath)}
136
- className={`w-7 h-7 rounded flex items-center justify-center text-sm transition-colors ${
137
- isVerified
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
- function ConfidenceBadge({ score }: { score: number }) {
179
- const pct = Math.round(score * 100)
180
- const [bg, text] =
181
- pct >= 90
182
- ? ['bg-green-100 text-green-700', '']
183
- : pct >= 70
184
- ? ['bg-yellow-100 text-yellow-700', '']
185
- : ['bg-red-100 text-red-600', '']
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
186
 
 
 
 
 
 
 
 
 
 
187
  return (
188
- <span className={`text-xs font-mono px-1.5 py-0.5 rounded ${bg} ${text}`}>
189
- {pct}%
190
- </span>
 
 
 
 
 
 
 
 
 
 
191
  )
192
  }
193
 
194
- function CheckIcon() {
 
 
 
 
 
 
 
 
 
 
 
195
  return (
196
- <svg className="w-3 h-3" viewBox="0 0 12 12" fill="currentColor">
197
- <path d="M10 3L5 8.5 2 5.5" stroke="currentColor" strokeWidth="1.5"
198
- strokeLinecap="round" strokeLinejoin="round" fill="none" />
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) - 24)
40
  })
41
  obs.observe(el)
42
- setContainerWidth(Math.floor(el.clientWidth) - 24)
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
- {pdfFiles.length > 1 && (
104
- <div className="flex flex-wrap gap-2 p-3 border-b flex-shrink-0" style={{ backgroundColor: '#1F2937' }}>
105
- {pdfFiles.map((f) => (
 
 
106
  <button
107
  key={f}
 
 
108
  onClick={() => setActivePdf(f)}
109
- className="px-3 py-1 rounded text-xs font-medium transition-colors"
110
- style={activePdfFile === f
111
- ? { backgroundColor: '#008080', color: '#ffffff' }
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
- </div>
119
- )}
120
 
121
  {/* PDF scroll area */}
122
  <div
123
  ref={containerRef}
124
- className="flex-1 overflow-y-auto pdf-scroll-container bg-gray-200 p-3 space-y-4"
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 shadow-md transition-shadow overflow-hidden ${
155
- hasActive ? 'ring-4 ring-blue-500' : ''
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(59, 130, 246, 0.35)' /* blue-500 fill */
185
- : 'rgba(134, 239, 172, 0.35)', /* green-300 fill */
186
  border: isActive
187
- ? '3px solid rgba(37, 99, 235, 1)' /* blue-700 solid */
188
- : '2px solid rgba(22, 163, 74, 0.9)', /* green-600 */
189
  borderRadius: 3,
190
  boxShadow: isActive
191
- ? '0 0 0 2px rgba(147, 197, 253, 0.6)' /* blue glow */
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-gray-400 text-sm">
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-gray-400 text-sm">
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-red-400 text-sm">
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 py-4 border-b flex-shrink-0" style={{ backgroundColor: '#1F2937' }}>
39
- <h2 className="text-sm font-semibold text-white">Golden Record</h2>
40
- <p className="text-xs mt-0.5" style={{ color: 'rgba(255,255,255,0.5)' }}>
41
- Click any field to highlight its source location in the PDF.
42
- </p>
43
  </div>
44
 
45
  {/* Scrollable field list */}
46
- <div className="flex-1 overflow-y-auto px-4 py-3 space-y-5">
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
47
  {fieldsBySection.map(({ section, entries }) => (
48
  <section key={section}>
49
- <h3 className="text-xs font-semibold uppercase tracking-wider mb-2 px-1" style={{ color: '#008080' }}>
50
- {SECTION_LABELS[section] ?? section}
51
- </h3>
52
- <div className="space-y-1">
 
 
 
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 logoUrl from './assets/ai-toolstack-logo.svg'
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 verified = Object.values(reviewState).filter((r) => r.action === 'verify').length
15
- const overridden = Object.values(reviewState).filter((r) => r.action === 'override').length
 
 
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" style={{ backgroundColor: '#f1f5f9' }}>
21
-
22
- {/* ── Top bar ─────────────────────────────────────────────────── */}
23
- <header className="flex items-center justify-between px-6 py-3 bg-white border-b border-gray-200 shadow-sm z-10 flex-shrink-0">
24
- <div className="flex items-center gap-4">
25
- <a href="https://www.ai-toolstack.com/" target="_blank" rel="noopener noreferrer">
26
- <img src={logoUrl} alt="AI Tool Stack" className="h-6 w-auto" />
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
- <div className="flex items-center gap-3 text-xs text-gray-500">
43
- <Stat label="Fields" value={fieldTotal} />
44
- <StatDivider />
45
- <Stat label="Located" value={provTotal} />
46
- <StatDivider />
47
- <Stat label="Verified" value={verified} color="#16a34a" />
48
- <StatDivider />
49
- <Stat label="Overridden" value={overridden} color="#2563EB" />
 
 
 
 
 
 
 
 
 
 
 
 
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-gray-200 flex flex-col overflow-hidden">
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
- function StatDivider() {
67
- return <span className="text-gray-200 select-none">·</span>
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
- <span>
81
- {label}:{' '}
82
- <span className="font-semibold" style={{ color }}>{value}</span>
83
- </span>
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" style={{ backgroundColor: '#f8fafc' }}>
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-gray-500">Loading session…</p>
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" style={{ backgroundColor: '#f8fafc' }}>
63
  <div className="text-center space-y-4 max-w-sm">
64
- <p className="text-sm text-red-600 bg-red-50 border border-red-200 rounded-xl px-4 py-3">
65
  {error}
66
  </p>
67
  <button
68
  onClick={() => navigate('/')}
69
- className="text-sm font-medium underline"
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 logoUrl from './assets/ai-toolstack-logo.svg'
7
-
8
- const BRAND = {
9
- dark: '#07111D',
10
- blue: '#5166D6',
11
- teal: '#4EA8A8',
12
- mint: '#63E0DC',
13
- } as const
 
 
 
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
- if (isMounted) setSamplePack(pack)
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
- const msg = err instanceof Error ? err.message : 'Could not load the demo sample files.'
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
- const msg = err instanceof Error ? err.message : 'An unknown error occurred.'
84
- setError(msg)
85
  setLoading(false)
86
  }
87
  }
88
 
89
  return (
90
- <div className="min-h-screen flex flex-col" style={{ backgroundColor: '#f8fafc' }}>
91
-
92
- {/* ── Top nav ─────────────────────────────────────────────────── */}
93
- <header className="flex items-center justify-between px-8 py-4 border-b border-gray-200 bg-white">
94
- <a href="https://www.ai-toolstack.com/" target="_blank" rel="noopener noreferrer">
95
- <img src={logoUrl} alt="AI Tool Stack" className="h-7 w-auto" />
96
- </a>
97
- <span
98
- className="text-xs font-medium px-2 py-1 rounded-full"
99
- style={{ backgroundColor: '#f0fdfc', color: BRAND.teal }}
100
- >
101
- Beta
102
- </span>
 
103
  </header>
104
 
105
- {/* ── Hero ────────────────────────────────────────────────────── */}
106
- <main className="flex-1 flex flex-col items-center justify-center px-8 py-12">
107
- <div className="w-full max-w-lg">
108
-
109
- {/* Title */}
110
- <div className="mb-8 text-center">
111
- <div className="inline-flex items-center gap-2 mb-4">
112
- <svg width="28" height="28" viewBox="0 0 28 28" fill="none" aria-hidden="true">
113
- <path d="M4 18L14 22L24 18" stroke={BRAND.blue} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
114
- <path d="M4 14L14 18L24 14" stroke={BRAND.teal} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
115
- <path d="M4 10L14 14L24 10L14 6L4 10Z" stroke={BRAND.mint} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
116
- </svg>
117
- <h1 className="text-2xl font-bold tracking-tight" style={{ color: BRAND.dark }}>
118
- PolicyTrace
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
- {/* Drop zone */}
128
- <div
129
- onDragOver={(e) => { e.preventDefault(); setDragOver(true) }}
130
- onDragLeave={() => setDragOver(false)}
131
- onDrop={(e) => {
132
- e.preventDefault()
133
- setDragOver(false)
134
- handleFiles(e.dataTransfer.files)
135
- }}
136
- onClick={() => document.getElementById('file-input')?.click()}
137
- className="rounded-2xl border-2 border-dashed p-10 text-center cursor-pointer transition-all"
138
- style={{
139
- borderColor: dragOver ? BRAND.blue : '#d1d5db',
140
- backgroundColor: dragOver ? '#eff6ff' : '#ffffff',
141
- }}
142
- >
143
- <svg
144
- className="mx-auto mb-3 h-10 w-10 transition-colors"
145
- fill="none"
146
- viewBox="0 0 24 24"
147
- stroke="currentColor"
148
- style={{ color: dragOver ? BRAND.blue : '#9ca3af' }}
 
 
 
 
 
 
 
 
 
 
149
  >
150
- <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5}
151
- d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
152
- </svg>
153
- <p className="text-sm font-medium text-gray-700">
154
- Drop PDF files here, or{' '}
155
- <span style={{ color: BRAND.blue }}>click to browse</span>
156
- </p>
157
- <p className="text-xs text-gray-400 mt-1">
158
- Schedule · Certificate · Statement of Fact · Policy Booklet
159
- </p>
160
- <input
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
- {samplePack?.files.length ? (
171
- <section className="mt-4 rounded-2xl border border-teal-100 bg-white p-4 shadow-sm">
172
- <div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
173
- <div>
174
- <h2 className="text-sm font-semibold" style={{ color: BRAND.dark }}>
175
- Try the synthetic demo pack
176
- </h2>
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
- <div className="mt-3 flex flex-wrap gap-2">
200
- {samplePack.files.map((sample) => (
201
- <a
202
- key={sample.filename}
203
- href={sample.url}
204
- target="_blank"
205
- rel="noopener noreferrer"
206
- className="inline-block max-w-full truncate rounded-full border border-gray-200 bg-gray-50 px-3 py-1 text-xs font-medium text-gray-600 hover:border-teal-200 hover:text-teal-700"
207
- >
208
- {sample.filename}
209
- </a>
 
 
 
210
  ))}
211
- </div>
212
- </section>
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
- {/* CTA */}
252
- <button
253
- onClick={handleSubmit}
254
- disabled={!files.length || loading}
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
- {loading && (
272
- <p className="text-center text-xs text-gray-400 mt-3">
273
- Classifying documents · Masking PII · Calling Groq LLM · Building provenance index
 
 
 
274
  </p>
275
- )}
276
  </div>
277
  </main>
278
 
279
- {/* ── Footer ──────────────────────────────────────��───────────── */}
280
- <footer className="text-center py-4 text-xs text-gray-400 border-t border-gray-200 bg-white">
281
- Built on{' '}
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 &amp; 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 &amp; 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-gray-50 text-gray-900;
8
- font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
9
  }
 
 
10
  }
11
 
12
- /* ── Brand accent utilities ─────────────────────────────────────── */
13
- .btn-primary {
14
- @apply py-3 px-6 rounded-xl font-semibold text-white transition-colors;
15
- background-color: #2563EB;
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- @apply shadow-md;
23
- }
24
- .react-pdf__Page__canvas {
25
- @apply block;
26
- }
 
 
27
 
28
- /* Smooth scroll for the PDF pane */
29
- .pdf-scroll-container {
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
- brand: {
11
- dark: '#1F2937',
12
- blue: '#2563EB',
13
- teal: '#008080',
14
- 50: '#f0fdfc',
15
- 100: '#ccfbf1',
16
- 600: '#2563EB',
17
- 700: '#1d4ed8',
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
18
  },
19
  },
20
  fontFamily: {
21
- sans: ['Inter', 'ui-sans-serif', 'system-ui', 'sans-serif'],
 
 
 
 
 
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
  },