npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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/interview

Peer 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:

  1. Host-supplied client — pass posthogClient; the package emits via the host's instance, with distinct_id overridden per event to the interview id. Host instance config (autocapture, identify, session recording) is the host's responsibility.
  2. Own instance — omit posthogClient; the package lazy-imports posthog-js and inits a named instance ('@codaco/interview') against https://ph-relay.networkcanvas.com.
  3. 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 set and unset, 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 runtime
  • InterviewI18nProvider — package locale boundary for exported controls used outside Shell

Schemas + helpers

  • StageMetadataSchema — Zod schema for session.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 for ResolvedAsset.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.