// Direction 3 — Technical Instrument (refined) // Two variants: 'console' (dark sidebar, denser, more instrument character) // 'paper' (light sidebar, calmer, fewer grid lines) const TI_ACCENTS = { forge: { ink: '#1D7A52', soft: '#eef6f1', tint: '#f0f6f3', name: 'Forge green' }, amber: { ink: '#a86a14', soft: '#fbf1de', tint: '#fbf3e2', name: 'Survey amber' }, slate: { ink: '#27506e', soft: '#e9eff5', tint: '#eef3f8', name: 'Field slate' }, rust: { ink: '#a83f1e', soft: '#fbece5', tint: '#fbeee8', name: 'Iron rust' }, }; const MONO_STACKS = { jetbrains: '"JetBrains Mono", ui-monospace, monospace', plex: '"IBM Plex Mono", ui-monospace, monospace', geist: '"Geist Mono", ui-monospace, monospace', }; const DENSITY = { compact: { row: 6, block: 10, page: 24 }, regular: { row: 8, block: 14, page: 32 }, roomy: { row: 12, block: 18, page: 44 }, }; function tiTheme(opts) { const variant = opts.variant || 'console'; const accent = TI_ACCENTS[opts.accent || 'forge']; const mono = MONO_STACKS[opts.mono || 'jetbrains']; const grid = opts.grid !== false; const d = DENSITY[opts.density || 'regular']; const dark = variant === 'console'; return { variant, dark, grid, mono, d, accent: accent.ink, accentSoft: accent.soft, accentTint: accent.tint, bg: dark ? '#f5f4ee' : '#fbfaf5', paper: '#fff', ink: '#0d0f0d', ink2: '#5a5e58', ink3: '#9a9e96', line: dark ? '#c9c8bf' : '#e1ddcf', line2: dark ? '#dfdcd0' : '#ebe6d4', side: dark ? '#15181a' : '#f0ecdf', sideInk: dark ? '#c8cec3' : '#3d3a30', sideInk2: dark ? '#737972' : '#8a8472', sideInk3: dark ? '#fff' : '#0d0f0d', sideRule: dark ? '#262a28' : '#dcd5be', red: '#9a2a1a', amberIn: '#a86a14', }; } function TI_Icon({ name, size = 12 }) { const p = { width: size, height: size, viewBox: '0 0 16 16', fill: 'none', stroke: 'currentColor', strokeWidth: 1.5, strokeLinecap: 'round', strokeLinejoin: 'round' }; if (name === 'check') return ; if (name === 'download') return ; if (name === 'upload') return ; if (name === 'arrow') return ; if (name === 'warn') return ; if (name === 'x') return ; return null; } function TIReadout({ value, label, theme, width = 48, accent }) { const [shown, setShown] = React.useState(value); React.useEffect(() => { let raf, start; const from = shown; const to = value; if (from === to) return; const dur = 280; const step = (t) => { if (!start) start = t; const k = Math.min(1, (t - start) / dur); setShown(from + (to - from) * (1 - Math.pow(1 - k, 3))); if (k < 1) raf = requestAnimationFrame(step); }; raf = requestAnimationFrame(step); return () => cancelAnimationFrame(raf); }, [value]); return (
{label}
{Math.round(shown).toString().padStart(3, '0')}
); } function TIRibbon({ status, theme }) { const map = { idle: { dot: theme.ink3, label: 'AWAITING DATA' }, ready: { dot: theme.accent, label: 'READY TO GENERATE' }, generating: { dot: theme.accent, label: 'PROCESSING', pulse: true }, success: { dot: theme.accent, label: 'COMPLETE' }, error: { dot: theme.red, label: 'ERROR' }, }; const s = map[status] || map.idle; return (
{s.label}
); } function useCsvPreview(file) { const [data, setData] = React.useState(null); React.useEffect(() => { if (!file) { setData(null); return; } const isXlsx = /\.xlsx?$|\.xlsm$/i.test(file.name); if (isXlsx) { setData({ kind: 'xlsx', cols: ['(Excel preview not shown)'], rows: [], totalRows: '?' }); return; } const reader = new FileReader(); reader.onload = (e) => { const text = String(e.target.result || ''); const lines = text.split(/\r?\n/).filter(Boolean).slice(0, 80); const split = (l) => { const out = []; let cur = ''; let q = false; for (let i = 0; i < l.length; i++) { const c = l[i]; if (c === '"') q = !q; else if (c === ',' && !q) { out.push(cur); cur = ''; } else cur += c; } out.push(cur); return out; }; const cols = (lines[0] ? split(lines[0]) : []).slice(0, 8); const rows = lines.slice(1, 9).map(l => split(l).slice(0, 8)); setData({ kind: 'csv', cols, rows, totalRows: lines.length - 1 }); }; reader.readAsText(file.slice(0, 32 * 1024)); }, [file]); return data; } function TechInstrument({ initialStatus, opts }) { const theme = React.useMemo(() => tiTheme(opts || {}), [opts]); const meta = useSurveyForgeMeta(); const { state, set, setFile, reset, generate, simulate } = useSurveyForgeForm({ initialStatus }); const [drag, setDrag] = React.useState(false); const fileRef = React.useRef(null); const preview = useCsvPreview(state.file); React.useEffect(() => { if (initialStatus && initialStatus !== 'idle') simulate(initialStatus); }, [initialStatus]); React.useEffect(() => { if (meta.surveyTypes && !state.surveyType) set({ surveyType: meta.surveyTypes[0]?.slug || '' }); if (meta.templates && !state.templateSlug) set({ templateSlug: meta.templates.default || '' }); }, [meta.surveyTypes, meta.templates]); const [now, setNow] = React.useState(() => new Date()); React.useEffect(() => { const id = setInterval(() => setNow(new Date()), 1000); return () => clearInterval(id); }, []); const ts = now.toISOString().replace('T', ' ').slice(0, 19) + ' UTC'; const onDrop = (e) => { e.preventDefault(); setDrag(false); const f = e.dataTransfer.files?.[0]; if (f) setFile(f); }; const surveyTypes = meta.surveyTypes || SF_FALLBACK_SURVEY_TYPES; const templates = (meta.templates || SF_FALLBACK_TEMPLATES).templates; const styles = makeTIStyles(theme); const expectedCols = React.useMemo(() => { const map = { reptile: ['site', 'date', 'observer', 'visit', 'species', 'count', 'lifestage', 'weather'], dormouse: ['site', 'date', 'box_id', 'occupied', 'species', 'count', 'observer'], badger: ['site', 'date', 'feature', 'easting', 'northing', 'activity', 'observer'], gcn: ['site', 'date', 'pond_id', 'method', 'count', 'sex', 'observer'], }; return map[state.surveyType] || ['site', 'date', 'observer', 'count']; }, [state.surveyType]); const colsOk = preview?.kind === 'csv' && expectedCols.every(c => preview.cols.some(pc => pc.toLowerCase().includes(c) || c.includes(pc.toLowerCase()))); return (
SF-01 / Rev 04 / Generate from data
{ts}
WT
NEW REPORT · FORM SF-01

Generate a report from your survey data

Pick a survey type, attach your CSV or Excel file, and we'll return a CIEEM-compliant Word document. Most jobs complete in five to ten seconds.

{(state.surveyType || '—').toUpperCase()}
set({ siteName: e.target.value })} /> set({ clientName: e.target.value })} /> set({ surveyorName: e.target.value })} /> set({ projectNumber: e.target.value })} /> set({ reportTitle: e.target.value })} /> set({ reportDate: e.target.value })} />
{templates.map((t, i) => { const active = !state.customTemplateFile && state.templateSlug === t.slug; return (
set({ templateSlug: t.slug, customTemplateFile: null })}>
{active && }
{t.name}
{t.tagline}
{String(i + 1).padStart(2, '0')}
); })}
Or upload your own .docx
{state.customTemplateFile ? state.customTemplateFile.name : "Use your consultancy's letterhead, fonts, and styles"}
{state.customTemplateFile ? ( ) : ( )}
{!state.file ? (
fileRef.current?.click()} onDragOver={(e) => { e.preventDefault(); setDrag(true); }} onDragLeave={() => setDrag(false)} onDrop={onDrop} >
DROP FILE HERE — OR BROWSE
.CSV .XLSX .XLSM .XLS · MAX 10MB
) : (
{(state.file.name.split('.').pop() || 'CSV').toUpperCase().slice(0, 4)}
{state.file.name}
SIZE {fileSize(state.file).toUpperCase()} {preview?.kind === 'csv' && <> · {preview.cols.length} COLS · {preview.totalRows} ROWS} {' · '} {preview?.kind === 'csv' ? (colsOk ? 'SCHEMA OK' : 'SCHEMA WARN') : 'PREVIEW N/A'}
{preview?.kind === 'csv' && preview.cols.length > 0 && (
PREVIEW · FIRST {preview.rows.length} ROWS SCROLL →
{preview.cols.map((c, i) => { const matched = expectedCols.some(ec => c.toLowerCase().includes(ec) || ec.includes(c.toLowerCase())); return ( ); })} {preview.rows.map((r, ri) => ( {preview.cols.map((_, ci) => ( ))} ))}
{c || `col_${i+1}`} {!matched && ?}
{r[ci] ?? ''}
{!colsOk && (
Some headers don't match the expected schema for {(state.surveyType || '').toUpperCase()}. Generation may still work — the API will tell you for sure.
)}
)}
)} e.target.files?.[0] && setFile(e.target.files[0])} />
{state.status === 'generating' && } {state.status === 'success' && } {state.status === 'error' && } {state.status !== 'success' && (
READY CHECK
Survey type Template Data file
EST. 5–10 SEC
)}
); } function PhotoBlock({ state, set, theme, styles }) { const fileRef = React.useRef(null); const [drag, setDrag] = React.useState(false); const addFiles = (files) => { if (!files || !files.length) return; const allowed = ['image/jpeg', 'image/png', 'image/gif', 'image/bmp', 'image/tiff']; const next = []; for (const f of files) { const ok = allowed.includes(f.type) || /\.(jpe?g|png|gif|bmp|tiff?)$/i.test(f.name); if (!ok) continue; next.push({ file: f, caption: '' }); } if (next.length) set({ photos: [...state.photos, ...next] }); }; const updateCaption = (idx, caption) => { const next = state.photos.slice(); next[idx] = { ...next[idx], caption }; set({ photos: next }); }; const removePhoto = (idx) => { const next = state.photos.filter((_, i) => i !== idx); set({ photos: next }); }; const onDrop = (e) => { e.preventDefault(); setDrag(false); addFiles(e.dataTransfer.files); }; return (
fileRef.current?.click()} onDragOver={(e) => { e.preventDefault(); setDrag(true); }} onDragLeave={() => setDrag(false)} onDrop={onDrop} >
DROP PHOTOS HERE — OR{' '} BROWSE
.JPG .PNG .GIF .BMP .TIFF · MULTIPLE OK
addFiles(e.target.files)} /> {state.photos.length > 0 && (
{state.photos.map((p, i) => (
FIGURE {i + 1}
{p.file.name}
updateCaption(i, e.target.value)} />
))}
)}
); } function NavRow({ theme, code, label, active, disabled, badge }) { return (
{code} {label} {badge && {badge}} {active && }
); } function Block({ theme, title, meta, children, accentBorder }) { const styles = makeTIStyles(theme); return (
{title}
{meta}
{children}
); } function Row({ theme, label, children, last }) { const styles = makeTIStyles(theme); return (
{label}
{children}
); } function CheckItem({ ok, theme, children }) { return (
{ok && }
{children}
); } function RegisterMarks({ theme }) { if (!theme.grid) return null; const c = theme.line; const M = ({ style }) => (
); return ( <> ); } function TIGenerating({ state, theme, styles }) { const stepIdx = state.progress < 30 ? 0 : state.progress < 60 ? 1 : state.progress < 90 ? 2 : 3; const stepLabels = [ 'READING DATA FILE', `APPLYING TEMPLATE: ${(state.templateSlug || 'DEFAULT').toUpperCase()}`, 'ASSEMBLING DOCUMENT', 'FINALISING OUTPUT', ]; return (
PROCESSING
{Math.round(state.progress).toString().padStart(3, '0')}%
{stepLabels.map((label, i) => (
{i < stepIdx ? '✓' : i === stepIdx ? '▸' : '·'} {label}{i === stepIdx && }
))}
); } function TISuccess({ state, theme, styles, onReset }) { return (
COMPLETE
EXIT 0 · 100%
OK
OUTPUT FILE
{state.downloadName}
FORMAT .DOCX · CIEEM-COMPLIANT · GENERATED {new Date().toISOString().slice(0, 19).replace('T', ' ')}
DOWNLOAD .DOCX
); } function TIError({ state, theme, styles, onRetry, onReset }) { const code = state.errorKind === 'columns' ? 'ERR_COLUMN_MISMATCH' : state.errorKind === 'server' ? 'ERR_SERVER' : state.errorKind === 'network' ? 'ERR_NETWORK' : 'ERR_REQUEST'; const exit = state.errorKind === 'server' ? 500 : state.errorKind === 'network' ? -1 : 400; return (
{code}
EXIT {exit}
{state.error}
{state.errorKind === 'columns' && (
HINT — Verify CSV headers match the schema for survey type
{(state.surveyType || '').toUpperCase()}. Expected columns are listed in the API docs.
)}
); } function makeTIStyles(theme) { return { root: { width: '100%', height: '100%', display: 'flex', background: theme.bg, color: theme.ink, fontFamily: theme.mono, fontSize: 13, lineHeight: 1.55, }, side: { width: 240, padding: '20px 18px', background: theme.side, color: theme.sideInk, display: 'flex', flexDirection: 'column', flexShrink: 0, fontSize: 12, borderRight: theme.dark ? 'none' : `1px solid ${theme.sideRule}`, }, sideHd: { fontSize: 9, letterSpacing: '0.2em', color: theme.sideInk2, textTransform: 'uppercase', marginBottom: 12, fontWeight: 500, }, brand: { display: 'flex', alignItems: 'center', gap: 12, paddingBottom: 18, borderBottom: `1px solid ${theme.sideRule}`, marginBottom: 18, }, brandMark: { width: 28, height: 28, background: theme.accent, color: '#fff', display: 'grid', placeItems: 'center', fontSize: 11, fontWeight: 700, letterSpacing: '-0.02em', borderRadius: 2, }, brandText: { color: theme.sideInk3, fontSize: 14, letterSpacing: '0.01em', fontWeight: 600 }, brandKicker: { fontSize: 9, color: theme.sideInk2, letterSpacing: '0.16em', marginTop: 2 }, readoutsWrap: { paddingTop: 18, borderTop: `1px solid ${theme.sideRule}`, marginTop: 18 }, readouts: { display: 'flex', gap: 14, marginBottom: 18 }, sideMeta: { borderTop: `1px solid ${theme.sideRule}`, paddingTop: 12, fontSize: 10, color: theme.sideInk2, lineHeight: 1.7, letterSpacing: '0.04em', }, main: { flex: 1, overflow: 'auto', display: 'flex', flexDirection: 'column' }, topbar: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '14px 32px', borderBottom: `1px solid ${theme.line}`, background: theme.paper, }, topL: { display: 'flex', alignItems: 'center', gap: 20 }, topR: { display: 'flex', alignItems: 'center', gap: 14 }, formId: { fontSize: 10, letterSpacing: '0.18em', color: theme.ink2, textTransform: 'uppercase' }, avatar: { width: 26, height: 26, background: theme.line2, color: theme.ink, display: 'grid', placeItems: 'center', fontSize: 10, fontWeight: 600, letterSpacing: '0.04em' }, page: { padding: `${theme.d.page}px 40px 80px`, maxWidth: 920, width: '100%', position: 'relative' }, hd: { marginBottom: 24, paddingBottom: 16, borderBottom: `2px solid ${theme.ink}` }, kicker: { fontSize: 10, letterSpacing: '0.2em', color: theme.ink2, textTransform: 'uppercase', marginBottom: 10 }, h1: { fontSize: 26, fontWeight: 600, letterSpacing: '-0.015em', margin: '0 0 10px', color: theme.ink, fontFamily: theme.mono }, lede: { fontSize: 13, color: theme.ink2, margin: 0, maxWidth: '64ch', lineHeight: 1.65 }, block: { border: `1px solid ${theme.line}`, background: theme.paper, marginBottom: theme.d.block, }, blockHd: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '10px 16px', borderBottom: `1px solid ${theme.line2}`, background: theme.dark ? '#fafaf6' : '#fdfbf3', }, blockTitle: { fontSize: 11, fontWeight: 600, letterSpacing: '0.08em', textTransform: 'uppercase' }, blockMeta: { fontSize: 10, color: theme.ink3, letterSpacing: '0.08em' }, blockBody: { padding: '12px 16px' }, rowLabel: { fontSize: 11, color: theme.ink2, padding: `${theme.d.row + 2}px 12px ${theme.d.row + 2}px 0`, borderBottom: `1px solid ${theme.line2}`, letterSpacing: '0.06em', }, rowField: { padding: '4px 0', borderBottom: `1px solid ${theme.line2}` }, inputBare: { background: 'transparent', border: 'none', padding: '6px 8px', fontSize: 13, fontFamily: theme.mono, color: theme.ink, outline: 'none', width: '100%', }, selectBare: { background: 'transparent', border: 'none', padding: '6px 24px 6px 8px', fontSize: 13, fontFamily: theme.mono, color: theme.ink, outline: 'none', width: '100%', appearance: 'none', backgroundImage: `url("data:image/svg+xml;utf8,")`, backgroundRepeat: 'no-repeat', backgroundPosition: 'right 8px center', }, tplGrid: { display: 'grid', gridTemplateColumns: 'repeat(5, 1fr)', border: `1px solid ${theme.line2}` }, tplCell: (active) => ({ padding: '12px 12px', borderRight: `1px solid ${theme.line2}`, background: active ? theme.accentTint : theme.paper, cursor: 'pointer', position: 'relative', minHeight: 92, transition: 'background .12s', }), tplCheck: (active) => ({ width: 12, height: 12, border: `1.5px solid ${active ? theme.accent : theme.line}`, background: active ? theme.accent : '#fff', display: 'grid', placeItems: 'center', color: '#fff', marginBottom: 8, }), tplName: { fontSize: 11, fontWeight: 700, letterSpacing: '0.04em', marginBottom: 4, textTransform: 'uppercase' }, tplTag: { fontSize: 10.5, color: theme.ink2, lineHeight: 1.45 }, tplIdx: { position: 'absolute', top: 10, right: 12, fontSize: 9, color: theme.ink3, letterSpacing: '0.1em' }, drop: (active, has) => ({ border: `1px dashed ${active ? theme.accent : theme.line}`, padding: has ? '14px' : '36px 14px', background: has ? theme.accentTint : (active ? theme.accentTint : (theme.dark ? '#fafaf6' : '#fdfbf3')), cursor: has ? 'default' : 'pointer', textAlign: has ? 'left' : 'center', transition: 'background .12s, border-color .12s', }), fileRow: { display: 'flex', alignItems: 'center', gap: 14 }, fileBadge: { width: 38, height: 38, background: '#fff', border: `1px solid ${theme.line}`, display: 'grid', placeItems: 'center', fontSize: 9, fontWeight: 700, letterSpacing: '0.06em', color: theme.accent, }, fileName: { fontSize: 13, fontWeight: 600, color: theme.ink, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }, fileMeta: { fontSize: 10, color: theme.ink2, letterSpacing: '0.06em', marginTop: 4 }, previewWrap: { marginTop: 12, border: `1px solid ${theme.line2}`, background: '#fff' }, previewHd: { display: 'flex', justifyContent: 'space-between', padding: '6px 10px', borderBottom: `1px solid ${theme.line2}`, background: theme.dark ? '#fafaf6' : '#fdfbf3', }, previewLabel: { fontSize: 9, letterSpacing: '0.16em', color: theme.ink3 }, previewScroll: { overflowX: 'auto' }, previewTable: { borderCollapse: 'collapse', fontSize: 11, fontFamily: theme.mono, width: '100%', minWidth: 600 }, previewTh: { textAlign: 'left', padding: '6px 10px', borderBottom: `1px solid ${theme.line2}`, borderRight: `1px solid ${theme.line2}`, fontSize: 10, letterSpacing: '0.06em', textTransform: 'lowercase', whiteSpace: 'nowrap', background: theme.dark ? '#fafaf6' : '#fdfbf3', }, previewTd: { padding: '5px 10px', borderBottom: `1px solid ${theme.line2}`, borderRight: `1px solid ${theme.line2}`, color: theme.ink2, whiteSpace: 'nowrap', maxWidth: 160, overflow: 'hidden', textOverflow: 'ellipsis', }, previewWarn: { padding: '8px 10px', fontSize: 11, color: theme.amberIn, borderTop: `1px solid ${theme.line2}`, background: '#fdf6e7', letterSpacing: '0.02em' }, actionPanel: { display: 'grid', gridTemplateColumns: '1fr auto', gap: 24, alignItems: 'center', padding: '18px 18px', background: theme.paper, border: `1.5px solid ${theme.ink}`, marginTop: 8, }, actionLeft: { display: 'flex', flexDirection: 'column', gap: 8 }, actionLabel: { fontSize: 9, letterSpacing: '0.2em', color: theme.ink3, textTransform: 'uppercase' }, actionList: { display: 'flex', gap: 18, flexWrap: 'wrap' }, actionRight: { textAlign: 'right' }, primary: (disabled) => ({ height: 44, padding: '0 22px', background: disabled ? theme.line : theme.ink, color: '#fff', border: 'none', fontFamily: theme.mono, fontSize: 12, fontWeight: 700, letterSpacing: '0.12em', textTransform: 'uppercase', cursor: disabled ? 'default' : 'pointer', display: 'inline-flex', alignItems: 'center', gap: 10, transition: 'background .12s', }), primaryAccent: { height: 40, padding: '0 18px', background: theme.accent, color: '#fff', border: 'none', fontFamily: theme.mono, fontSize: 11, fontWeight: 700, letterSpacing: '0.12em', textTransform: 'uppercase', cursor: 'pointer', display: 'inline-flex', alignItems: 'center', gap: 10, textDecoration: 'none', }, ghost: { height: 36, padding: '0 14px', background: theme.paper, color: theme.ink, border: `1px solid ${theme.line}`, fontFamily: theme.mono, fontSize: 10, fontWeight: 600, letterSpacing: '0.1em', textTransform: 'uppercase', cursor: 'pointer', }, }; } Object.assign(window, { TechInstrument, tiTheme });