Sources and outputField extraction
Built by DesignMe, free to use under the MIT licence.
Expert design and dev support for startups and enterprises.
Book a callField extraction
The agent pulls fields out of a document and shows where each came from. Free and MIT licensed, as HTML or a typed React component, in two styles with light and dark mode.
Install
Add it to your project with the CLI.
HTML, CSS and JavaScript
npx designme add field-extractionReact
npx designme add field-extraction --reactThe CLI copies the source into your project, so you own it and can change anything. The HTML version lands in ./designme with the shared core and a demo.html. The React version lands in src/components/designme and needs React 19; it works in Next.js, Vite and Astro.
About
What it is for and what it does.
Field extraction shows a document next to the fields an agent reads from it, such as an invoice number, date and total. Each field shows how sure the agent is, lights up the part of the page it came from, and asks the person to check it when confidence is low.
- When an agent pulls structured data out of invoices, receipts, forms or contracts.
- When people need to see where on the page each value came from.
- When low-confidence values should be checked by a person before the data is saved.
- When the result goes on to another tool, like a spreadsheet, once everything is confirmed.
- Wrap parts of your
pageinFieldZonewith a field key. The zone lights up when its field arrives, when the field is hovered and while it is checked. - Each field shows a
label, avalueand a confidence bar. Fields under 0.6 confidence show a Check button and mark their zone. - Check turns the value into an editable field, prefilled with
suggestionif given. Enter or clicking away confirms and callsonFix(key, value). - Set
fixedon a checked field and it shows "Fixed" with a full bar. readingsends a scan line down the page each time it turns true, and the status line crossfades asstatuschanges.- The send button stays disabled until reading has stopped and every field is sure or fixed.
sendStatemoves it through idle, sending and sent. - The document and the fields sit side by side, the fields' column narrowing with the component, and stack once the component itself is 560px wide or less, whatever the screen size.
- The value being checked is an editable
role="textbox"witharia-label="Value", focused when it opens. Enter confirms it. - The scan line is
aria-hidden. - The send button is a disabled button while fields still need checking.
Usage
Import it and drive it from your own state.
Import
import { FieldExtraction } from '@/components/designme/field-extraction/FieldExtraction';Example
The demo above as a React component, 58 lines you can copy.
'use client';
// The animated preview from designme.dev, rebuilt from the React component: an example of driving it from your own state.
import { useEffect, useRef, useState } from 'react';
import { FieldExtraction, FieldZone, type ExtractedField } from './FieldExtraction';
import { useScript } from '../lib/useScript';
/* ---------- Field extraction: the agent reads an invoice field by field, one date is smudged and needs a look ---------- */
const FOUND: ExtractedField[] = [
{ key: 'no', label: 'Invoice no.', value: 'NW-2041', confidence: 0.99 },
{ key: 'date', label: 'Issued', value: '2026-10-03', confidence: 0.97 },
{ key: 'total', label: 'Total', value: '$11,480.00', confidence: 0.98 },
{ key: 'due', label: 'Due by', value: '2026-11-1?', confidence: 0.41, suggestion: '2026-11-1' }
];
const INVOICE = (
<>
<div className="dm-fex-top"><b>Northwind Studio</b><span className="dm-muted">Invoice</span></div>
<FieldZone field="no"><span className="dm-muted">Invoice no.</span> <span className="dm-num">NW-2041</span></FieldZone>
<FieldZone field="date"><span className="dm-muted">Issued</span> <span className="dm-num">3 Oct 2026</span></FieldZone>
<div className="dm-fex-tb">
<div className="dm-fex-li"><span>Design system audit</span><span className="dm-num">$4,800.00</span></div>
<div className="dm-fex-li"><span>Pricing page redesign</span><span className="dm-num">$6,200.00</span></div>
<div className="dm-fex-li"><span>Agent setup, 2 seats</span><span className="dm-num">$480.00</span></div>
</div>
<FieldZone field="total" className="dm-fex-tot"><span>Total due</span><span className="dm-num">$11,480.00</span></FieldZone>
<FieldZone field="due" className="dm-fex-smudge"><span className="dm-muted">Due by</span> <span className="dm-num">1? Nov 2026</span></FieldZone>
</>
);
export default function FieldExtractionDemo() {
const [fields, setFields] = useState<ExtractedField[]>([]);
const [reading, setReading] = useState(false);
const [status, setStatus] = useState('Reading page 1');
const [send, setSend] = useState<'idle' | 'sending' | 'sent'>('idle');
const later = useRef<ReturnType<typeof setTimeout>>(undefined);
useEffect(() => () => clearTimeout(later.current), []);
useScript(async ({ wait }) => {
await wait(300); setReading(true);
for (const f of FOUND) { await wait(560); setFields(list => [...list, f]); }
await wait(400); setReading(false); setStatus('1 field needs a look');
});
// the day is the smudged digit; anything left unfinished becomes the 14th, as the agent guessed
const fix = (key: string, value: string) => {
setFields(list => list.map(f => f.key === key ? { ...f, value: /\d\d$/.test(value) ? value : '2026-11-14', fixed: true } : f));
setStatus('All fields confirmed');
};
const sendRow = () => {
setSend('sending');
later.current = setTimeout(() => { setSend('sent'); setStatus('1 row added to Invoices 2026'); }, 800);
};
return (
<div style={{ width: '100%' }}>
<FieldExtraction page={INVOICE} fields={fields} reading={reading} status={status} checkHint="Type the day you see" onFix={fix}
sendLabel="Send to Sheets" sentLabel="Added to Invoices" sendState={send} onSend={sendRow} />
</div>
);
}
Props
Everything FieldExtraction takes.
| Prop | Type | What it does |
|---|---|---|
pagerequired | ReactNode | The page being read, on the left. Wrap the parts the fields come from in FieldZone |
fieldsrequired | ExtractedField[] | The fields found so far, in order. Each new one slides in and its zone lights up for a moment |
reading | boolean | A scan line passes down the page each time this turns true, and Send waits while it is true |
status | string | The quiet line by the title, like "Reading page 1". Crossfades when it changes |
checkHint | string | Replaces the status while a field is being checked |
onFix | (key: string, value: string) => void | Called when a checked field is confirmed with Enter or by clicking away |
title | string | |
sendLabel | string | The button under the fields. It waits until every field is sure or checked |
sendState | 'idle' | 'sending' | 'sent' | idle shows sendLabel, sending a spinner, sent a check and sentLabel |
sentLabel | string | |
onSend | () => void | |
className | string |
Code
The full source, as the CLI installs it.
FieldExtraction.tsxReact · 134 lines
'use client';
import { createContext, useContext, useEffect, useRef, useState, type KeyboardEvent, type ReactNode } from 'react';
import { cx } from '../lib/cx';
import { useIsoLayoutEffect } from '../lib/hooks';
import { Icon } from '../lib/icons';
import { Spin, Swap, Tip } from '../lib/motion';
import '../designme.css';
import './field-extraction.css';
export interface ExtractedField {
/** Ties the field to the FieldZone it was read from */
key: string;
/** Like "Invoice no." */
label: string;
value: string;
/** 0 to 1. Under 0.6 the field asks the person to check it */
confidence: number;
/** What the value starts as when the person checks it, like the value without its unsure digit */
suggestion?: string;
/** The person has checked it. Shows "Fixed" with a full bar */
fixed?: boolean;
}
export interface FieldExtractionProps {
/** The page being read, on the left. Wrap the parts the fields come from in FieldZone */
page: ReactNode;
/** The fields found so far, in order. Each new one slides in and its zone lights up for a moment */
fields: ExtractedField[];
/** A scan line passes down the page each time this turns true, and Send waits while it is true */
reading?: boolean;
/** The quiet line by the title, like "Reading page 1". Crossfades when it changes */
status?: string;
/** Replaces the status while a field is being checked */
checkHint?: string;
/** Called when a checked field is confirmed with Enter or by clicking away */
onFix?: (key: string, value: string) => void;
title?: string;
/** The button under the fields. It waits until every field is sure or checked */
sendLabel?: string;
/** idle shows sendLabel, sending a spinner, sent a check and sentLabel */
sendState?: 'idle' | 'sending' | 'sent';
sentLabel?: string;
onSend?: () => void;
className?: string;
}
const needsCheck = (f: ExtractedField) => f.confidence < 0.6 && !f.fixed;
type Zones = { lit: string | null; scan: string[]; warn: string[] };
const ZoneContext = createContext<Zones>({ lit: null, scan: [], warn: [] });
/** A part of the page a field was read from. It lights up while its field is read, hovered or checked */
export function FieldZone({ field, className, children }: { field: string; className?: string; children: ReactNode }) {
const z = useContext(ZoneContext);
return <div className={cx('dm-fex-z', z.scan.includes(field) && 'dm-scan', z.warn.includes(field) && 'dm-warn', z.lit === field && 'dm-hl', className)}>{children}</div>;
}
/** The value while it is being checked: the person types into the field itself */
function Editing({ start, onDone }: { start: string; onDone: (value: string) => void }) {
const ref = useRef<HTMLSpanElement>(null);
const done = useRef(false);
useIsoLayoutEffect(() => {
const v = ref.current; if (!v) return;
v.textContent = start; v.focus();
const s = getSelection(); s?.selectAllChildren(v); s?.collapseToEnd();
}, []); // eslint-disable-line react-hooks/exhaustive-deps
const finish = () => { if (done.current) return; done.current = true; onDone(ref.current?.textContent ?? start); };
const key = (e: KeyboardEvent) => { if (e.key === 'Enter') { e.preventDefault(); finish(); } };
return <span ref={ref} className="dm-v dm-num" contentEditable="true" suppressContentEditableWarning role="textbox" aria-label="Value" onKeyDown={key} onBlur={finish} />;
}
/** Fields an agent reads off a document, each with how sure it is. Unsure ones ask the person to check them. */
export function FieldExtraction({ page, fields, reading = false, status = '', checkHint = 'Type the value you see', onFix, title = 'Extracted fields',
sendLabel = 'Send', sendState = 'idle', sentLabel = 'Sent', onSend, className }: FieldExtractionProps) {
const [hover, setHover] = useState<string | null>(null);
const [editing, setEditing] = useState<string | null>(null);
// a new field lights its zone for half a second
const [scan, setScan] = useState<string[]>([]);
const known = useRef(new Set(fields.map(f => f.key)));
const timers = useRef<ReturnType<typeof setTimeout>[]>([]);
useEffect(() => () => timers.current.forEach(clearTimeout), []);
useEffect(() => {
const fresh = fields.map(f => f.key).filter(k => !known.current.has(k));
if (!fresh.length) return;
fresh.forEach(k => known.current.add(k));
setScan(s => [...s, ...fresh]);
timers.current.push(setTimeout(() => setScan(s => s.filter(k => !fresh.includes(k))), 500));
}, [fields]);
// each time reading starts, a new scan line passes down the page; it stays parked below the page after
const [passes, setPasses] = useState(reading ? 1 : 0);
const [wasReading, setWasReading] = useState(reading);
if (reading !== wasReading) { setWasReading(reading); if (reading) setPasses(p => p + 1); }
const zones: Zones = { lit: editing ?? hover, scan, warn: fields.filter(needsCheck).map(f => f.key) };
const ready = !reading && fields.length > 0 && !fields.some(needsCheck);
return (
<ZoneContext.Provider value={zones}>
<div data-dm="" className={cx('dm-fex', className)}>
<div className="dm-fex-doc">
<div className="dm-fex-pg">{page}</div>
{passes > 0 && <div key={passes} className="dm-fex-beam dm-go" aria-hidden="true" />}
</div>
<div className="dm-fex-side dm-panel">
<div className="dm-fex-sh"><b>{title}</b><Swap className="dm-muted dm-fex-st">{editing ? checkHint : status}</Swap></div>
<div className="dm-fex-list">
{fields.map(f => {
const warn = needsCheck(f), pct = Math.round(f.confidence * 100);
return (
<div key={f.key} className={cx('dm-fex-r', warn && 'dm-warn', 'dm-enter')} onMouseEnter={() => setHover(f.key)} onMouseLeave={() => setHover(h => h === f.key ? null : h)}>
<span className="dm-k">{f.label}</span>
{editing === f.key
? <Editing key="edit" start={f.suggestion ?? f.value} onDone={v => { setEditing(null); onFix?.(f.key, v); }} />
: <span key="view" className="dm-v dm-num">{f.value}</span>}
{warn
? <span className="dm-fex-cf"><Tip label={`${pct}% sure, check it`}><button type="button" className="dm-fex-fix" onClick={() => setEditing(f.key)}><Icon name="edit" size="xs" />Check</button></Tip></span>
: <span key={f.fixed ? 'fixed' : 'sure'} className={cx('dm-fex-cf', f.fixed && 'dm-enter')}><i><b style={{ width: (f.fixed ? 100 : pct) + '%' }} /></i>{f.fixed ? 'Fixed' : pct + '%'}</span>}
</div>
);
})}
</div>
<div className="dm-fex-f">
<button type="button" className={cx('dm-pill dm-sm', sendState === 'sent' ? 'dm-soft' : 'dm-primary')} disabled={!ready} onClick={() => { if (sendState === 'idle') onSend?.(); }}>
{sendState === 'sending' ? <Spin /> : sendState === 'sent' ? <><Icon name="check" size="xs" />{sentLabel}</> : sendLabel}
</button>
</div>
</div>
</div>
</ZoneContext.Provider>
);
}
field-extraction.cssReact · 47 lines
/* Generated from designme.dev by scripts/sync.mjs. Edit the site, then sync. */
/* ---------- Field extraction ---------- */
.dm-fex{display:grid;grid-template-columns:minmax(0,1fr) clamp(240px,44%,316px);gap:14px;width:100%;container:fex/inline-size}
/* the document and the fields side by side while the component has room for both, whatever the screen; one above the other below that */
@container fex (max-width:560px){.dm-fex-doc,.dm-fex-side{grid-column:1/-1}}
.dm-fex-doc{border-radius:18px;background:var(--dm-chip);padding:var(--dm-s-5);display:grid;place-items:center;min-width:0;position:relative;overflow:hidden}
/* the invoice is paper: white ink-on-paper in light mode, a dark document in dark mode */
.dm-fex-pg{--dm-pg:#fff;--dm-pg-fg:#232323;--dm-pg-mut:#6a6a6a;--dm-pg-line:#e8e8e8;--dm-pg-hl:var(--dm-accent-mark,var(--dm-accent));--dm-pg-ring:var(--dm-accent-mark,var(--dm-accent));--dm-pg-warn:232,155,20;--dm-pg-wring:#e89b14;--dm-pg-sh:0 1px 2px rgba(0,0,0,.08),0 8px 30px rgba(0,0,0,.08);
width:min(330px,100%);background:var(--dm-pg);color:var(--dm-pg-fg);border-radius:6px;padding:var(--dm-s-5) 22px;box-shadow:var(--dm-pg-sh);font-size:12px;display:flex;flex-direction:column;gap:var(--dm-s-2);position:relative}
:root[data-theme="dark"] .dm-fex-doc{background:color-mix(in srgb,var(--dm-chip) 40%,var(--dm-stage))}
@media (prefers-color-scheme:dark){:root:not([data-theme="light"]) .dm-fex-doc{background:color-mix(in srgb,var(--dm-chip) 40%,var(--dm-stage))}}
:root[data-theme="dark"] .dm-fex-pg{--dm-pg:var(--dm-raised);--dm-pg-fg:var(--dm-fg);--dm-pg-mut:var(--dm-muted);--dm-pg-line:var(--dm-line);--dm-pg-warn:240,168,48;--dm-pg-wring:#f0a830;--dm-pg-sh:0 0 0 1px var(--dm-line),0 10px 30px rgba(0,0,0,.4)}
@media (prefers-color-scheme:dark){:root:not([data-theme="light"]) .dm-fex-pg{--dm-pg:var(--dm-raised);--dm-pg-fg:var(--dm-fg);--dm-pg-mut:var(--dm-muted);--dm-pg-line:var(--dm-line);--dm-pg-warn:240,168,48;--dm-pg-wring:#f0a830;--dm-pg-sh:0 0 0 1px var(--dm-line),0 10px 30px rgba(0,0,0,.4)}}
.dm-fex-pg .dm-muted{color:var(--dm-pg-mut)}
/* a label under the scan's tint or a highlight leans toward the ink, so it keeps 4.5:1 with any accent */
.dm-fex-z:is(.dm-scan,.dm-hl) .dm-muted{color:color-mix(in srgb,var(--dm-pg-mut) 70%,var(--dm-pg-fg))}
.dm-fex-top{display:flex;justify-content:space-between;align-items:baseline;margin-bottom:6px;font-size:13px}
.dm-fex-z{position:relative;padding:2px var(--dm-s-1);margin:0 -4px;border-radius:var(--dm-r-xs);transition:background .3s,box-shadow .3s}
.dm-fex-z.dm-scan{background:color-mix(in srgb,var(--dm-pg-hl) 8%,transparent)}
.dm-fex-z.dm-hl{background:color-mix(in srgb,var(--dm-pg-hl) 12%,transparent);box-shadow:0 0 0 1.5px var(--dm-pg-ring)}
.dm-fex-z.dm-warn.dm-hl{background:rgba(var(--dm-pg-warn),.14);box-shadow:0 0 0 1.5px var(--dm-pg-wring)}
.dm-fex-tb{border-top:1px solid var(--dm-pg-line);border-bottom:1px solid var(--dm-pg-line);padding:6px 0;display:flex;flex-direction:column;gap:var(--dm-s-1)}
.dm-fex-li{display:flex;justify-content:space-between;gap:10px}
.dm-fex-tot{display:flex;justify-content:space-between;font-weight:500;font-size:13px}
.dm-fex-smudge .dm-num{filter:blur(.4px)}
.dm-fex-beam{position:absolute;left:0;right:0;height:40px;background:linear-gradient(180deg,transparent,color-mix(in srgb,var(--dm-pg-hl) 12%,transparent),transparent);pointer-events:none;top:-40px}
.dm-fex-beam.dm-go{animation:dm-fexb 2.6s var(--dm-ease-io) both}
@keyframes dm-fexb{to{top:100%}}
.dm-fex-side{padding:var(--dm-s-3);display:flex;flex-direction:column;gap:var(--dm-s-2);min-width:0}
.dm-fex-sh{display:flex;justify-content:space-between;align-items:baseline;font-size:13px;padding:2px var(--dm-s-1)}
.dm-fex-sh b{font-weight:500}
.dm-fex-st{font-size:12px}
.dm-fex-list{display:flex;flex-direction:column;gap:2px;flex:1}
.dm-fex-r{display:grid;grid-template-columns:70px 1fr auto;align-items:center;gap:var(--dm-s-2);padding:7px 6px;border-radius:10px;font-size:13px;transition:background .2s}
@media (hover:hover) and (pointer:fine){.dm-fex-r:hover{background:var(--dm-row)}}
.dm-fex-r .dm-k{font-size:12px;color:var(--dm-muted)}
.dm-fex-r .dm-v{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;outline:0;border-radius:6px;padding:1px var(--dm-s-1);margin:0 -4px}
.dm-fex-r .dm-v[contenteditable="true"]{background:var(--dm-surface);box-shadow:0 0 0 1px color-mix(in srgb,var(--dm-field-line) 50%,transparent)}
.dm-fex-r .dm-v[contenteditable="true"]:focus{box-shadow:0 0 0 1px color-mix(in srgb,var(--dm-field-line-strong) 50%,transparent)}
.dm-fex-cf{display:flex;align-items:center;gap:5px;font-size:11.5px;color:var(--dm-muted)}
.dm-fex-cf i{width:22px;height:4px;border-radius:2px;background:var(--dm-line);overflow:hidden;display:block}
.dm-fex-cf i b{display:block;height:100%;border-radius:2px;background:var(--dm-ok);transform-origin:left;animation:dm-ssrel .7s var(--dm-ease) both}
.dm-fex-r.dm-warn .dm-fex-cf i b{background:var(--dm-warn)}
.dm-fex-fix{font-size:12px;color:var(--dm-warn);display:inline-flex;align-items:center;gap:var(--dm-s-1)}
@keyframes dm-ssrel{from{transform:scaleX(0)}}
.dm-fex-f{display:flex;justify-content:flex-end}
field-extraction.htmlHTML · 26 lines
<div class="card wide" data-c="field-extraction">
<div class="stage-in">
<div class="fex">
<div class="fex-doc">
<div class="fex-pg">
<div class="fex-top"><b>Northwind Studio</b><span class="muted">Invoice</span></div>
<div class="fex-z" data-z="no"><span class="muted">Invoice no.</span> <span class="num">NW-2041</span></div>
<div class="fex-z" data-z="date"><span class="muted">Issued</span> <span class="num">3 Oct 2026</span></div>
<div class="fex-tb">
<div class="fex-li"><span>Design system audit</span><span class="num">$4,800.00</span></div>
<div class="fex-li"><span>Pricing page redesign</span><span class="num">$6,200.00</span></div>
<div class="fex-li"><span>Agent setup, 2 seats</span><span class="num">$480.00</span></div>
</div>
<div class="fex-z fex-tot" data-z="total"><span>Total due</span><span class="num">$11,480.00</span></div>
<div class="fex-z fex-smudge" data-z="due"><span class="muted">Due by</span> <span class="num">1? Nov 2026</span></div>
</div>
</div>
<div class="fex-side panel">
<div class="fex-sh"><b>Extracted fields</b><span class="muted fex-st">Reading page 1</span></div>
<div class="fex-list"></div>
<div class="fex-f"><button class="pill primary sm fex-go" disabled>Send to Sheets</button></div>
</div>
</div>
</div>
</div>
field-extraction.cssHTML · 46 lines
/* ---------- Field extraction ---------- */
.fex{display:grid;grid-template-columns:minmax(0,1fr) clamp(240px,44%,316px);gap:14px;width:100%;container:fex/inline-size}
/* the document and the fields side by side while the component has room for both, whatever the screen; one above the other below that */
@container fex (max-width:560px){.fex-doc,.fex-side{grid-column:1/-1}}
.fex-doc{border-radius:18px;background:var(--chip);padding:var(--s-5);display:grid;place-items:center;min-width:0;position:relative;overflow:hidden}
/* the invoice is paper: white ink-on-paper in light mode, a dark document in dark mode */
.fex-pg{--pg:#fff;--pg-fg:#232323;--pg-mut:#6a6a6a;--pg-line:#e8e8e8;--pg-hl:var(--accent-mark,var(--accent));--pg-ring:var(--accent-mark,var(--accent));--pg-warn:232,155,20;--pg-wring:#e89b14;--pg-sh:0 1px 2px rgba(0,0,0,.08),0 8px 30px rgba(0,0,0,.08);
width:min(330px,100%);background:var(--pg);color:var(--pg-fg);border-radius:6px;padding:var(--s-5) 22px;box-shadow:var(--pg-sh);font-size:12px;display:flex;flex-direction:column;gap:var(--s-2);position:relative}
:root[data-theme="dark"] .fex-doc{background:color-mix(in srgb,var(--chip) 40%,var(--stage))}
@media (prefers-color-scheme:dark){:root:not([data-theme="light"]) .fex-doc{background:color-mix(in srgb,var(--chip) 40%,var(--stage))}}
:root[data-theme="dark"] .fex-pg{--pg:var(--raised);--pg-fg:var(--fg);--pg-mut:var(--muted);--pg-line:var(--line);--pg-warn:240,168,48;--pg-wring:#f0a830;--pg-sh:0 0 0 1px var(--line),0 10px 30px rgba(0,0,0,.4)}
@media (prefers-color-scheme:dark){:root:not([data-theme="light"]) .fex-pg{--pg:var(--raised);--pg-fg:var(--fg);--pg-mut:var(--muted);--pg-line:var(--line);--pg-warn:240,168,48;--pg-wring:#f0a830;--pg-sh:0 0 0 1px var(--line),0 10px 30px rgba(0,0,0,.4)}}
.fex-pg .muted{color:var(--pg-mut)}
/* a label under the scan's tint or a highlight leans toward the ink, so it keeps 4.5:1 with any accent */
.fex-z:is(.scan,.hl) .muted{color:color-mix(in srgb,var(--pg-mut) 70%,var(--pg-fg))}
.fex-top{display:flex;justify-content:space-between;align-items:baseline;margin-bottom:6px;font-size:13px}
.fex-z{position:relative;padding:2px var(--s-1);margin:0 -4px;border-radius:var(--r-xs);transition:background .3s,box-shadow .3s}
.fex-z.scan{background:color-mix(in srgb,var(--pg-hl) 8%,transparent)}
.fex-z.hl{background:color-mix(in srgb,var(--pg-hl) 12%,transparent);box-shadow:0 0 0 1.5px var(--pg-ring)}
.fex-z.warn.hl{background:rgba(var(--pg-warn),.14);box-shadow:0 0 0 1.5px var(--pg-wring)}
.fex-tb{border-top:1px solid var(--pg-line);border-bottom:1px solid var(--pg-line);padding:6px 0;display:flex;flex-direction:column;gap:var(--s-1)}
.fex-li{display:flex;justify-content:space-between;gap:10px}
.fex-tot{display:flex;justify-content:space-between;font-weight:500;font-size:13px}
.fex-smudge .num{filter:blur(.4px)}
.fex-beam{position:absolute;left:0;right:0;height:40px;background:linear-gradient(180deg,transparent,color-mix(in srgb,var(--pg-hl) 12%,transparent),transparent);pointer-events:none;top:-40px}
.fex-beam.go{animation:fexb 2.6s var(--ease-io) both}
@keyframes fexb{to{top:100%}}
.fex-side{padding:var(--s-3);display:flex;flex-direction:column;gap:var(--s-2);min-width:0}
.fex-sh{display:flex;justify-content:space-between;align-items:baseline;font-size:13px;padding:2px var(--s-1)}
.fex-sh b{font-weight:500}
.fex-st{font-size:12px}
.fex-list{display:flex;flex-direction:column;gap:2px;flex:1}
.fex-r{display:grid;grid-template-columns:70px 1fr auto;align-items:center;gap:var(--s-2);padding:7px 6px;border-radius:10px;font-size:13px;transition:background .2s}
@media (hover:hover) and (pointer:fine){.fex-r:hover{background:var(--row)}}
.fex-r .k{font-size:12px;color:var(--muted)}
.fex-r .v{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;outline:0;border-radius:6px;padding:1px var(--s-1);margin:0 -4px}
.fex-r .v[contenteditable="true"]{background:var(--surface);box-shadow:0 0 0 1px color-mix(in srgb,var(--field-line) 50%,transparent)}
.fex-r .v[contenteditable="true"]:focus{box-shadow:0 0 0 1px color-mix(in srgb,var(--field-line-strong) 50%,transparent)}
.fex-cf{display:flex;align-items:center;gap:5px;font-size:11.5px;color:var(--muted)}
.fex-cf i{width:22px;height:4px;border-radius:2px;background:var(--line);overflow:hidden;display:block}
.fex-cf i b{display:block;height:100%;border-radius:2px;background:var(--ok);transform-origin:left;animation:ssrel .7s var(--ease) both}
.fex-r.warn .fex-cf i b{background:var(--warn)}
.fex-fix{font-size:12px;color:var(--warn);display:inline-flex;align-items:center;gap:var(--s-1)}
@keyframes ssrel{from{transform:scaleX(0)}}
.fex-f{display:flex;justify-content:flex-end}
field-extraction.jsHTML · 23 lines
DM.def('field-extraction', (r, c) => {
const F = [['no','Invoice no.','NW-2041',.99],['date','Issued','2026-10-03',.97],['total','Total','$11,480.00',.98],['due','Due by','2026-11-1?',.41]];
const list = $('.fex-list', r), st = $('.fex-st', r), go = $('.fex-go', r), doc = $('.fex-doc', r), pg = $('.fex-pg', r);
const z = k => $(`.fex-z[data-z="${k}"]`, r);
function row(f){
const warn = f[3] < .6;
const el = html(`<div class="fex-r${warn ? ' warn' : ''}" data-k="${f[0]}"><span class="k">${f[1]}</span><span class="v num">${f[2]}</span><span class="fex-cf">${warn ? `<button class="fex-fix" data-tip="${Math.round(f[3] * 100)}% sure, check it">${I('edit','xs')}Check</button>` : `<i><b style="width:${f[3] * 100}%"></b></i>${Math.round(f[3] * 100)}%`}</span></div>`);
if (warn) z(f[0]).classList.add('warn');
el.onmouseenter = () => z(f[0]).classList.add('hl'); el.onmouseleave = () => z(f[0]).classList.remove('hl');
const fx = $('.fex-fix', el);
if (fx) fx.onclick = () => { const v = $('.v', el); v.contentEditable = 'true'; v.textContent = '2026-11-1'; v.focus({ preventScroll:true }); const s = document.getSelection(); s.selectAllChildren(v); s.collapseToEnd(); z(f[0]).classList.add('hl'); swap(st, 'Type the day you see');
const done = () => { v.contentEditable = 'false'; z(f[0]).classList.remove('hl', 'warn'); el.classList.remove('warn'); const cf = $('.fex-cf', el); cf.innerHTML = `<i><b style="width:100%"></b></i>Fixed`; enter(cf); go.disabled = false; swap(st, 'All fields confirmed'); };
v.onkeydown = e => { if (e.key === 'Enter'){ e.preventDefault(); if (!/\d\d$/.test(v.textContent)) v.textContent = '2026-11-14'; done(); } }; v.onblur = () => { if (v.isContentEditable){ if (!/\d\d$/.test(v.textContent)) v.textContent = '2026-11-14'; done(); } }; };
return el;
}
go.onclick = () => c.run(async () => { go.innerHTML = '<span class="spin"></span>'; await c.wait(800); go.innerHTML = I('check','xs') + 'Added to Invoices'; go.className = 'pill soft sm fex-go'; swap(st, '1 row added to Invoices 2026'); });
c.run(async () => {
const beam = html('<div class="fex-beam"></div>'); doc.appendChild(beam); await c.wait(300); beam.classList.add('go');
for (const f of F){ await c.wait(560); const zz = z(f[0]); zz.classList.add('scan'); const el = row(f); list.appendChild(el); enter(el); c.later(500, () => zz.classList.remove('scan')); }
await c.wait(400); swap(st, '1 field needs a look');
});
});
Questions
What people ask about it.
- How do I link a field to the spot on the page it came from?
- Wrap that part of the page in
<FieldZone field="key">using the samekeyas the field. The component handles the highlight. - What counts as a field that needs checking?
- Any field with
confidencebelow 0.6 that is not markedfixed. Those fields block the send button until the person checks them. - Does checking a field update its value by itself?
- No. It calls
onFixwith the key and the typed value. Update the field in your state and setfixed: trueto show it as confirmed. - Can I stream fields in one by one?
- Yes. Append to
fieldsas the agent finds them. Each new field slides in and briefly lights its zone on the page.
More in Sources and output
What the agent read, what it produced and where each answer came from.