@codaco/interview
v9.1.0
Published
Network Canvas interview engine — Shell component, session contract.
Readme
@codaco/interview
The Network Canvas interview engine — packaged so any React host can embed the survey UI a participant interacts with. Owns the Redux store, stage navigation, all 17 interface implementations (NameGenerator, Sociogram, CategoricalBin, Geospatial, …), the dialog system, the toast system, and the design tokens. The host owns the network calls (sync, finish, asset URL resolution) and where the participant currently is in the protocol.
The intended hosts are Fresco (Next.js) and the new Architect preview mode (Vite) — but the package has no awareness of either.
Install
pnpm add @codaco/interviewPeer dependencies (you most likely already have these):
{
"@codaco/fresco-ui": "^2.5.4",
"@codaco/protocol-validation": "^11.5.0",
"@codaco/shared-consts": "5.0.0",
"@codaco/tailwind-config": "^1.0.0-alpha.11",
"immer": "^11.1.4",
"motion": "^12.38.0",
"react": "^19.2.5",
"react-dom": "^19.2.5",
"tailwindcss": "^4.2.4",
}Tailwind
The package assumes the host has a Tailwind v4 build plugin wired up
(@tailwindcss/vite for Vite, @tailwindcss/postcss for Next.js / any
PostCSS pipeline). Each CSS file in the chain owns one concern, and
consumers import them in order:
/* styles/globals.css */
@import '@codaco/tailwind-config/fresco.css'; /* Tailwind v4 + theme + plugins + fonts */
@import '@codaco/fresco-ui/styles.css'; /* @source glue for fresco-ui's classes */
@import '@codaco/interview/styles.css'; /* @source glue for interview's classes */Do not also @import "tailwindcss" yourself. That import lives
inside @codaco/tailwind-config/fresco.css; adding it again loads
Tailwind's runtime twice and produces duplicate / conflicting
utilities.
Each of @codaco/fresco-ui/styles.css and @codaco/interview/styles.css
is a tiny file containing only a @source "./**/*.{js,ts,tsx}"
directive scoped to that package's own module files. The glob resolves
relative to wherever the imported styles.css sits — the package's
src/ tree when consumed from source (as workspace hosts do), or the
mirrored dist/ copy for an npm install. With both imported, Tailwind's
class scanner walks both packages and emits every utility class the
components reference. Hosts no longer need to write
@source '../node_modules/...' lines themselves.
@codaco/tailwind-config/fresco.css bundles both the default and
interview theme variants. Shell renders <main data-theme-interview>
with a portal container so dialogs/popovers stay inside the themed
subtree — no host-side setup required. See Theming & DOM scope below.
Vitest / jsdom
Node has no CSS loader, so any vitest test that imports from
@codaco/interview (even just the schemas) needs Vite to process
the package, otherwise you get
"TypeError: Unknown file extension .css". Inline the package on
every project that uses jsdom:
// vitest.config.ts
export default defineConfig({
test: {
server: {
deps: { inline: ['@codaco/interview'] },
},
},
});The Shell component
import {
createDebouncedSyncHandler,
Shell,
type InterviewPayload,
} from '@codaco/interview';A complete minimal host (TypeScript / Next.js App Router style — the shape is identical for any other React framework):
'use client';
import {
createDebouncedSyncHandler,
Shell,
type AssetRequestHandler,
type FinishHandler,
type InterviewPayload,
type SyncHandler,
} from '@codaco/interview';
import { useCallback, useMemo, useState } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
export default function InterviewClient({
payload,
resolveAssetUrl,
}: {
payload: InterviewPayload;
resolveAssetUrl: (assetId: string) => Promise<string>;
}) {
const router = useRouter();
const params = useSearchParams();
// currentStep is HOST state — the package never owns it. Keep it in
// useState, nuqs, a context, the URL, wherever fits the host. The
// package re-renders when the prop changes.
const [currentStep, setCurrentStep] = useState<number>(() =>
Number(params.get('step') ?? payload.session.currentStep),
);
const onStepChange = useCallback(
(step: number) => {
setCurrentStep(step);
const next = new URLSearchParams(params.toString());
next.set('step', String(step));
router.replace(`?${next.toString()}`);
},
[params, router],
);
// Persist state on every reducer commit. Receives the full session
// payload — POST it to your server, write to IndexedDB, anything.
//
// The engine offers every change as it happens and never batches on your
// behalf: only you know what one write costs. A local write can take them
// all; a network request usually should not, so wrap it in
// `createDebouncedSyncHandler` (below) rather than posting per answer.
const onSync: SyncHandler = useMemo(
() =>
createDebouncedSyncHandler(
async (interviewId, session) => {
await fetch(`/interview/${interviewId}/sync`, {
method: 'POST',
body: JSON.stringify(session),
});
},
{ waitMs: 3000 },
),
[],
);
// Called when the participant clicks Finish on the FinishSession
// stage. Receives an AbortSignal so you can cancel any in-flight work
// if the user backs out.
const onFinish: FinishHandler = useCallback(
async (interviewId, signal) => {
await fetch(`/interview/${interviewId}/finish`, {
method: 'POST',
signal,
});
router.push(`/interview/${interviewId}/complete`);
},
[router],
);
// Stages reference protocol assets by ID. The package calls this
// exactly when a stage needs a URL — return a same-origin URL,
// pre-signed S3 URL, blob: URL, whatever your storage uses.
const onRequestAsset: AssetRequestHandler = useCallback(
(assetId) => resolveAssetUrl(assetId),
[resolveAssetUrl],
);
return (
<Shell
payload={payload}
currentStep={currentStep}
onStepChange={onStepChange}
onSync={onSync}
onFinish={onFinish}
onRequestAsset={onRequestAsset}
/>
);
}Why is currentStep host state?
So the host can drive it however it likes — nuqs URL params, browser
history, a stepper UI, deep links from email, server-rendered initial
position. The package reads currentStep and emits onStepChange for
every navigation; it does not maintain its own copy.
This is also what allows the host to mount Shell once but render
different stages without re-creating the Redux store: only the
currentStep prop changes between renders.
Shell props
| Prop | Type | Required | Notes |
| ------------------------------- | ------------------------------------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| payload | InterviewPayload | yes | { session, protocol } — see the type for shape. The store is created once per payload.session.id; pass a stable reference. |
| currentStep | number | yes | The stage index the participant is on. Owned by the host. |
| onStepChange | (step: number) => void | yes | Fired whenever the participant navigates. The host should mirror step into its own state. |
| onSync | (id, session, opts) => Promise | yes | Called after every Redux commit — the engine does not batch. Persist however you like; wrap in createDebouncedSyncHandler if writes are expensive. opts.immediate marks writes that must not be deferred (exit, finish); opts.unloading additionally marks the ones the document may not survive (hidden, pagehide). |
| onFinish | (id, AbortSignal) => Promise | yes | Called from the FinishSession stage. The signal aborts if the user navigates away mid-flight. |
| onRequestAsset | (assetId) => Promise<url> | yes | Resolve a protocol asset to a URL. Called lazily as stages mount. |
| analytics | InterviewAnalyticsMetadata | yes | Host metadata attached as super-properties on every event: installationId (anonymous host UUID), hostApp (e.g. "Fresco"), hostVersion?. |
| posthogClient | PostHog (from posthog-js) | no | Pre-initialised PostHog client. When provided, the package emits events through it without modifying its config. When absent, the package lazy-initialises its own named instance against ph-relay.networkcanvas.com. |
| disableAnalytics | boolean | no | When true, all event emission is suppressed (no posthog-js import). Default false. Use for E2E and synthetic-interview runs. |
| finishConfirmationDescription | ReactNode | no | Host-specific explanation shown in the finish confirmation dialog. Defaults to localized neutral guidance that does not promise responses are immutable. A subscribed message component can keep a host override responsive to language changes. |
| requestedLocale | string \| readonly string[] \| null | no | A user preference, resolved host locale, or ordered locale requests. The package negotiates against its own supported interface languages; unmatched requests use English. |
| localePreference | string \| null | no | Optional controlled menu choice, paired with onLocaleChange. A string selects the best supported match; null follows requestedLocale. Omit it to keep menu selection local to the package. |
| onLocaleChange | (locale: string \| null) => void | no | Called after a menu selection so the host can persist it. null means follow the host request again. |
| allowLanguageSelection | boolean | no | Show the interface language chooser in the settings menu. Defaults to true. |
| flags | { isE2E?, isDevelopment? } | no | isE2E: true exposes window.__interviewStore for Playwright fixtures. isDevelopment: true enables redux-logger. |
The package replaces a previous onError callback with internal posthog.captureException calls; render errors and asset-load failures are reported via the resolved analytics client (or suppressed when disableAnalytics is true). The host does not need to wire its own error sink.
Interface language
Pass the user's preference or your host's already negotiated locale as
requestedLocale. The package owns its registry and messages: it currently
supports en, en-GB, and es, matches regional requests such as es-MX to
es, and falls back to en for unsupported or malformed requests. An array
expresses requests in preference order. No host provider or catalog is required.
All supported messages are bundled, so switching language needs no network.
The setting controls package-provided buttons, menus, validation, accessibility labels, help, and stage controls. Protocol-authored titles, prompts, labels, options, research values, and identifiers are passed through unchanged. Protocol content localization is a separate schema concern.
The menu can temporarily override the host request. Its Automatic option clears
that override; a new host request also takes effect immediately. A host that
persists menu changes can use onLocaleChange and pass its newly resolved locale
back as requestedLocale. To mirror a saved explicit/automatic choice in the
menu, also pass localePreference: a string takes precedence and is matched
against the package registry; an unmatched or malformed preference falls
through to requestedLocale. null follows requestedLocale, and omitting it
keeps package-local menu state. This lets Automatic clear a saved preference
directly after a reload. The package itself reads no browser preference or
storage. Locale changes preserve the mounted interview, pending form input,
navigation and answers. The package sets lang and dir on its own region and
leaves the host document's language to the host.
Hosts rendering exported controls outside Shell, such as an inline
ProtocolField preview, can use InterviewI18nProvider from @codaco/interview
with the same requestedLocale contract and package-owned catalogs. Hosts that
already own a provider can instead merge interviewCatalogs from
@codaco/interview/locales into their app catalog. Without a provider,
standalone controls use their English defaults.
Analytics
The interview package emits PostHog events directly — there is no onError-style host bridge. Three operating modes:
- Host-supplied client — pass
posthogClient; the package emits via the host's instance, withdistinct_idoverridden per event to the interview id. Host instance config (autocapture, identify, session recording) is the host's responsibility. - Own instance — omit
posthogClient; the package lazy-importsposthog-jsand inits a named instance ('@codaco/interview') againsthttps://ph-relay.networkcanvas.com. - Disabled — pass
disableAnalytics={true}; no events emitted, no posthog-js import.
PII contract: events never include protocol-network data, protocol-author content (stage labels, prompt text, codebook labels, asset names), or participant input (form values, free-text, alter labels, search queries, passphrases). Events include only structural identifiers (stage type/index, prompt index, random node/edge UUIDs), codebook internal ids (e.g. "person", "friend"), counts, durations, and package-defined discriminators.
The full event taxonomy lives at docs/superpowers/specs/2026-05-05-interview-analytics-design.md.
Toast viewport
Shell mounts its own Base UI toast provider and viewport inside the themed,
localized interview region. Hosts do not need another interview viewport.
This keeps notification text and accessible names responsive to the interview
menu's language, including when the host uses a different language.
Each Shell keeps its validation notifications independent from other mounted interviews. An app-level toast provider can remain separate for host-owned notifications outside the interview.
Building an InterviewPayload server-side
The package never reaches into the host's database. Your server hands
over a fully resolved { session, protocol } object, with all
asset:// URLs already mapped to the IDs onRequestAsset will be
called with.
import {
type InterviewPayload,
type ProtocolPayload,
isValidAssetType,
} from '@codaco/interview';
import { hashProtocol } from '@codaco/protocol-validation';
export async function loadInterviewPayload(
interviewId: string,
): Promise<InterviewPayload> {
const interview = await db.interview.findUniqueOrThrow({
where: { id: interviewId },
include: { protocol: true },
});
// protocol assets come from your DB / object store — flatten to the
// shape the package consumes
const assets = interview.protocol.assets
.filter((a) => isValidAssetType(a.type))
.map((a) => ({
assetId: a.id,
name: a.name,
type: a.type,
...(a.value ? { value: a.value } : {}), // apikey assets only
}));
const protocol: ProtocolPayload = {
...interview.protocol,
// Content hash of { codebook, stages }. Computed at protocol-import time
// by `hashProtocol` from @codaco/protocol-validation; forwarded here as a
// super-property on every analytics event.
hash: interview.protocol.hash ?? hashProtocol(interview.protocol),
importedAt: interview.protocol.importedAt.toISOString(),
assets,
};
return {
session: {
id: interview.id,
// … the rest of SessionPayload — see the type for the full shape
network: interview.network ?? createInitialNetwork(),
currentStep: interview.currentStep ?? 0,
stageMetadata: interview.stageMetadata,
// …
},
protocol,
};
}createInitialNetwork() returns the canonical empty network with an ego
node already initialised — call it once when you create a new interview
record so subsequent loads pass schema validation.
Sparse entity attribute flow
Interview stores entity attributes as sparse records. An own attribute key
always has a defined VariableValue; a missing key means the variable is
unset. false, 0, '', and [] are defined responses and remain present.
flowchart LR
Form[Mounted form fields] --> Adapter[Coerce values and build patch]
Direct[Direct participant edit] --> Patch[AttributePatch: set and unset]
Adapter --> Patch
Patch --> Validate[Validate keys and overlap]
Validate --> Apply[Apply attributes and secure metadata]
Protocol[Protocol-derived prompt attributes] --> Apply
Apply --> Network[Sparse session.network]
Network --> Sync[onSync session]
Sync --> Persistence[Host persistence]
Persistence --> HostParse[Host read via NcNetworkSchema]
HostParse --> Payload[Shell payload]
Payload --> Network
Persistence --> ExportParse[Exporter parse via NcNetworkSchema]
ExportParse --> Output[CSV and GraphML]The internal AttributePatch contract separates setting defined values from
removing properties:
type AttributePatch = Readonly<{
set: Readonly<Record<string, VariableValue>>;
unset: readonly string[];
}>;Form submission first coerces values to their protocol variable types. The
adapter then considers exactly the mounted field names: each defined value goes
to set, each undefined value goes to unset, and unmounted fields are
ignored. If any mounted defined value is not a VariableValue, conversion
fails as a whole and no partial patch is dispatched.
Creation and editing deliberately use different parts of that result:
- Creating an entity writes only
set, so unanswered fields are omitted. - Editing applies both
setandunset, so clearing a mounted field deletes that key while attributes outside the form remain unchanged. - Direct participant edits, such as bins and layout controls, produce the same patch shape instead of writing nullish sentinels.
The node, edge, and ego update thunks validate that participant-edit patch keys
belong to the applicable codebook definition and reject a key that appears in
both set and unset. Node and edge creation apply the same key check to their
initial attributes; controlled roster and pedigree imports can explicitly
allow external node attributes. Validation completes before the fulfilled
reducer can mutate session state. Prompt-membership patches are derived directly
from the protocol's additionalAttributes, so reducers apply those trusted
keys without repeating patch validation.
The shared applicator produces new attribute and secure-metadata maps for both
validated and protocol-derived patches. An unset removes the attribute and
its matching _secureAttributes entry in the same reducer transition; an empty
secure-metadata map collapses to undefined.
Network Composer undo history is presence-sensitive. For every touched key, an
inverse patch restores a prior own value with set, but restores prior absence
with unset. This distinction is required when a defined empty value such as
[] is edited and then undone.
onSync hands the host the sparse session after reducer commits. Hosts parse
stored or received networks with NcNetworkSchema before constructing the next
Shell payload; this accepts nullish input entries but emits sparse output.
@codaco/network-exporters repeats that parse per session before formatting,
so persistence and export do not reintroduce nullish attribute values.
Public API reference
Everything below is exported from '@codaco/interview'. Additional public
subpaths expose the contract, protocol schema version, locale catalogs and
styles; host code should not reach into package internals.
Components
Shell— the runtimeInterviewI18nProvider— package locale boundary for exported controls used outsideShell
Schemas + helpers
StageMetadataSchema— Zod schema forsession.stageMetadata. Use it to validate state restored from your database before passing it back into a payload.createInitialNetwork()— empty network with ego seeded. Call once per new interview.isValidAssetType(type)— type predicate forResolvedAsset.type.getNodeLabelAttribute(variables, attributes)— pick the variable whose value should be displayed as a node's label. Used by sibling packages (e.g.@codaco/network-exporters) so exports use the same labelling logic the UI does.
Synthetic data
generateNetwork(codebook, stages, options?)— deterministic network generator that walks a protocol's stages and produces nodes, edges, ego attributes, and stage metadata. Use it for storybook fixtures, load tests, and the synthetic preview mode.GenerateNetworkOptions,GenerateNetworkResult— companion types.
Public types
type InterviewPayload = { session: SessionPayload; protocol: ProtocolPayload };
type SessionPayload = SessionState; // the Redux session shape
type ProtocolPayload = Omit<CurrentProtocol, 'assetManifest'> & {
id: string;
importedAt: string; // ISO
assets: ResolvedAsset[];
};
type ResolvedAsset = {
assetId: string;
name: string;
type: 'image' | 'video' | 'audio' | 'network' | 'geojson' | 'apikey';
value?: string; // apikey only
};
type SyncOptions = {
// Do not defer this write.
immediate: boolean;
// The document is being hidden or unloaded and may never run script again:
// use a transport that outlives it, and do not queue behind a request that
// will die with it. Always accompanied by `immediate`.
unloading: boolean;
};
type SyncHandler = (
interviewId: string,
session: SessionPayload,
options: SyncOptions,
) => Promise<void>;
// Batching is the host's decision. This wraps a handler so ordinary changes
// are rate-limited to one write per `waitMs` carrying the newest state, while
// `immediate` writes go out at once.
function createDebouncedSyncHandler(
write: SyncHandler,
options: { waitMs: number },
): SyncHandler;
type FinishHandler = (
interviewId: string,
signal: AbortSignal,
) => Promise<void>;
type AssetRequestHandler = (assetId: string) => Promise<string>;
type ErrorHandler = (error: Error, ctx?: Record<string, unknown>) => void;
type StepChangeHandler = (step: number) => void;
type InterviewerFlags = {
isE2E?: boolean;
isDevelopment?: boolean;
};Theming & DOM scope
Shell renders a single <main data-theme-interview> element. This is both the stable selector for tests / e2e fixtures and the wrapper that activates the interview theme: descendants pick up the dark palette, Nunito typography, and responsive root font-size automatically.
Shell also provides a portal container (via <PortalContainerProvider> from @codaco/fresco-ui/PortalContainer) so dialogs, popovers, dropdowns, tooltips, toasts, selects, and comboboxes opened from inside the interview render into a node inside the themed subtree — they inherit the interview palette automatically rather than portaling to document.body.
If you render interview-themed UI outside of Shell (e.g. a "thank you" page after the interview ends), wrap that UI with <ThemedRegion theme="interview"> from @codaco/fresco-ui/ThemedRegion:
import { ThemedRegion } from '@codaco/fresco-ui/ThemedRegion';
<ThemedRegion theme="interview">
<ThankYouPage />
</ThemedRegion>;<ThemedRegion> and its ancestors up to <body> must not have transform, filter, perspective, or contain set — these create new containing blocks for fixed-positioned descendants and would break modal/popover positioning.
Testing the integration
Unit / component tests (Vitest + jsdom)
See the Vitest / jsdom note above — inline @codaco/interview in
your config so the CSS side effect resolves cleanly.
End-to-end (Playwright)
For e2e you usually want a deterministic stand-in for the host's data
layer. The package supports this through the isE2E flag, which
exposes the live Redux store as window.__interviewStore so fixtures
can read network state without hitting your database:
<Shell {...props} flags={{ isE2E: true }} />The package's own e2e suite (in this repo) uses a Vite host that
implements window.__test hooks for installProtocol / createInterview /
reset, runs in the official mcr.microsoft.com/playwright image for
font-rendering determinism, and asserts against per-stage screenshots in
e2e/visual-snapshots/{chromium,firefox,webkit}-matrix/. Use it as a reference
for wiring your own e2e setup — see e2e/README.md for the
layout and the test-authoring pattern.
For interactive debugging (no test runner), run pnpm dev:host to boot
the Vite host + asset server, prepare the silos protocol, and open a browser
tab that lands directly in step 0 of the interview — no console paste.
What lives in this package, what doesn't
In:
- the Redux store + every reducer / selector / thunk
- all 17 stage interfaces and the navigation chrome
- the dialog system, toast system, and stage error boundary
- the synthetic network generator
- the contract types and the schemas the host serialises against
Out:
- everything that touches a database, a session cookie, or the network
- protocol parsing and validation (use
@codaco/protocol-validation) - export to GraphML / CSV (use
@codaco/network-exporters) - network filtering / query DSL (use
@codaco/network-query)
When in doubt: if it would still make sense to ship it embedded inside a non-Fresco host (Architect's preview mode, a CLI, an Electron app), it belongs here. If it talks to Fresco's Postgres or its auth layer, it doesn't.
License
MIT — see the repository root.
