Writing answers
ProctoredTextarea: paste/typing/checkpoint/focus signals, autosave, and saving the answer to your own backend.
On this page
<ProctoredTextarea> is a Vue component (available today); the React equivalent is 🚧 in progress. See Choose your integration. Its engine — createTextAnswerRecorder in @a4anthony/proctorkit-sdk — is a shipped framework-agnostic primitive you can wire to your own textarea now.
<ProctoredTextarea> is a drop-in textarea for writing questions that captures how an answer was produced — the integrity signals a reviewer needs — alongside the answer text. It is the writing counterpart to the audio/video recorders.
<ProctoredTextarea
v-model="answer"
question-id="q1"
:save-answer="saveToMyBackend"
/>With no configuration it is already fully proctored. Props only turn signals off or enable enforcement.
The two-database boundary
There are two destinations, with two different owners — this is the most important thing to understand:
| Data | Destination | Who writes it |
|---|---|---|
| The answer text | your backend | you, via v-model + saveAnswer |
| Integrity signals | the proctoring server | the SDK, automatically |
The proctoring SDK has no path into your database (a security boundary — it runs in the candidate's browser). So <ProctoredTextarea> delegates the answer save to a function you provide (saveAnswer), and shows the Saving… → Saved ✓ status around it. The recorder uploads its clip to the proctoring server itself; the textarea hands the answer to your saveAnswer. Same status UX, correct ownership.
The integrity signals (text.paste, text.checkpoint, …) flow through the SDK's event pipeline to the proctoring server automatically and power the dashboard's Writing tab.
Integrity signals captured
| Signal | What it captures |
|---|---|
| Paste | count, size, and the % of the answer that was pasted vs typed (optional content, PII-gated) |
| Copy / cut | count of copy/cut from the field (the selection size), an exfil signal — moving the answer out |
| Typing dynamics | sampled keystroke cadence, backspace/correction rate, active typing time |
| Content checkpoints | periodic snapshots so a reviewer can replay how the answer grew (also the server autosave backstop) |
| Field focus/blur | leaving the field mid-answer (count + duration) |
| Writing-assistant footprint | Grammarly/AI-extension detection — injected DOM (data-gramm), untrusted (isTrusted:false) input, and spellcheck/AI text replacement |
The writing-assistant signal records a footprint, not a guarantee. It catches the obvious cases (an enabled extension, programmatic text replacement) but a determined candidate can disable injection or use a separate device. It is never blocked — only flagged for review.
Autosave & refresh durability
The candidate's in-progress answer survives a refresh:
- Local (primary): debounced
localStorage, keyed by(sessionId, questionId). Survives F5 and a tab close+reopen. Wiped on submit. - Server backstop (
persistAnswer): content checkpoints carry the answer text to the proctoring server, so it can rehydrate even when local storage is gone (different device, cleared storage). - Conflict: on restore, the newer of (local, server checkpoint) wins, by timestamp.
By default the Saving… → Saved ✓ status reflects only your networked saveAnswer — a local draft write isn't a backend save, so it doesn't claim "Saved ✓". If the field's only persistence is the local draft and you still want the candidate to see their work is captured, set localSaveStatus and the status follows the local autosave instead. It appears only once typing pauses and the debounced draft write runs (never mid-typing — nothing is being saved while the candidate is still typing), briefly showing Saving… for localSaveDelayMs (default 500ms) before Saved ✓.
In a sandboxed iframe (opaque origin) the browser denies
localStorage, so local autosave silently no-ops. There, the server checkpoint is the only refresh-durable path — setpersistAnswerfor iframe-embedded runners.
Props
Signal and autosave props default on; you set props only to disable a signal or enable enforcement.
| Prop | Type | Default | Description |
|---|---|---|---|
modelValue | string | "" | The answer text (v-model). Owned by you. |
questionId | string | required | Scopes events, autosave, and checkpoints. |
client | ProctoringClient | - | SDK client. Omit → active session client → standalone. |
sessionId | string | - |
The #meta slot (for fully overriding the counter) is passed: charCount, wordCount, metMinimum, atMaximum, remainingToMin, plus pasteCount, pastedRatio, copyCount, cutCount, keystrokeCount, backspaceCount, typingMs (active typing time), syntheticDetected, and saveState.
The component sets anti-assistant attributes on its textarea (data-gramm="false", translate="no", …) so writing assistants and translators are discouraged, in addition to being detected.
Exposed via a template ref:
submit(extra?)— call when the candidate submits to firetext.submittedand clear the local draft. Pass{ metMinimum }(your word/char-limit result) and it is forwarded on the event so a reviewer can tell an incomplete answer from a suspicious one. The limit rule itself stays yours.dismissKeyboard()— blur the field (e.g. from your own "Done" button) to close the soft keyboard.
const field = ref<InstanceType<typeof ProctoredTextarea>>();
// on your submit:
field.value?.submit({ metMinimum: wordCount >= minLength });Saving to your backend
saveAnswer is called debounced as the candidate types and again on submit. Resolve when your save succeeds (→ Saved ✓), throw when it fails (→ inline error with a stable code).
<script setup lang="ts">
import { ProctoredTextarea } from "@a4anthony/proctorkit-vue";
import type { SaveAnswerFn } from "@a4anthony/proctorkit-vue";
const saveAnswer: SaveAnswerFn = async ({ text, questionId, signal }) => {
const res = await fetch("https://api.example.com/answers"
method
headers authorization
body questionId text
signal
resok
script
template
vmodel
questionid
saveanswer
@saved
@savefailed
templateEvents
| Event | Payload | When |
|---|---|---|
update:modelValue | string | Every input. |
paste | { length, pastedRatio, blocked } | A paste occurred. |
copy / cut | { length, blocked } | A copy/cut occurred (length = selection size). |
synthetic-input | { source } | A writing-assistant / synthetic footprint was seen. |
checkpoint |
Standalone (no proctoring client)
With no client (and no active session), the field still works fully — typing, autosave, your saveAnswer — but the integrity signals have nowhere to go, so they are dropped. Useful for non-proctored use, docs, and Storybook. This mirrors the audio/video standalone fallback.
On the dashboard
Each writing answer gets a Writing tab on the session detail page showing paste %, typing dynamics, a writing-assistant footprint badge, focus-away count, and a checkpoint replay of how the answer grew. The tab is flagged with a ⚠ when any answer shows a writing-assistant footprint or heavy paste.