// 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 (
);
}
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
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 (
|
{c || `col_${i+1}`}
{!matched && ?}
|
);
})}
{preview.rows.map((r, ri) => (
{preview.cols.map((_, ci) => (
| {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 (
);
}
function Row({ theme, label, children, last }) {
const styles = makeTIStyles(theme);
return (
);
}
function CheckItem({ ok, theme, children }) {
return (
);
}
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 (
OUTPUT FILE
{state.downloadName}
FORMAT .DOCX · CIEEM-COMPLIANT · GENERATED {new Date().toISOString().slice(0, 19).replace('T', ' ')}
);
}
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 (
{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 });