@codaco/interview
v7.1.1
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 {
Shell,
InterviewToastViewport,
interviewToastManager,
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 {
Shell,
InterviewToastViewport,
interviewToastManager,
type AssetRequestHandler,
type FinishHandler,
type InterviewPayload,
type SyncHandler,
} from '@codaco/interview';
import { Toast } from '@base-ui/react/toast';
import { useCallback, 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.
const onSync: SyncHandler = useCallback(async (interviewId, session) => {
await fetch(`/interview/${interviewId}/sync`, {
method: 'POST',
body: JSON.stringify(session),
});
}, []);
// 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 (
<Toast.Provider toastManager={interviewToastManager}>
<Shell
payload={payload}
currentStep={currentStep}
onStepChange={onStepChange}
onSync={onSync}
onFinish={onFinish}
onRequestAsset={onRequestAsset}
/>
<InterviewToastViewport />
</Toast.Provider>
);
}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) => Promise | yes | Called after every Redux commit. Persist the session however you like. |
| 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 | string | no | Host-specific explanation shown in the finish confirmation dialog. Defaults to neutral guidance that does not promise responses are immutable. |
| 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.
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
The package routes toasts through a Base UI Toast.Provider you mount.
Render exactly one <InterviewToastViewport /> somewhere inside that
provider — it controls position, animation, and z-index for all toasts
the package emits (validation errors, save indicators, etc.).
<Toast.Provider toastManager={interviewToastManager}>
<Shell {...props} />
<InterviewToastViewport />
</Toast.Provider>You can also enqueue your own toasts against interviewToastManager
from anywhere in the host tree — useful for surfacing app-level
errors inside the same viewport.
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.
Public API reference
Everything below is exported from '@codaco/interview'. There are no
sub-path exports — host code never reaches into the package's internals.
Components
Shell— the runtimeInterviewToastViewport— Base UI toast viewport mounted next toShell
Singletons
interviewToastManager— the toast manager passed intoToast.Provider
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 SyncHandler = (
interviewId: string,
session: SessionPayload,
) => Promise<void>;
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.
