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

podhouse-investor-qa

v0.4.1

Published

Headless playback engine for the Podhouse Investor Q&A — answer-window seek, speech-timed callouts, layout-aware captions, HLS via hls.js, fullscreen. A React hook plus optional default-styled, themeable components. No branding baked in.

Downloads

1,339

Readme

podhouse-investor-qa

The headless playback engine behind the Podhouse Investor Q&A — the tuned behavior (answer-window seek, speech-timed callouts, layout-aware captions, HLS via hls.js, the "answer card" seek gate, mobile fullscreen, a collapsible text-answer panel, and an audio-only listen mode) as a React hook plus optional default-styled, themeable components. No branding or client-specific styling is baked in.

You (the host site) fetch the payload from your own dataUrl route — a server-to-server call to our per-site Basic-auth API — and pass it straight in. Build against the bundled ./fixtures today; the real API returns the same shape, so your views don't change when it swaps in.

Install

npm install podhouse-investor-qa

Peer deps: react (>=18) and hls.js (^1, optional — needed only for playback). Both are already present in the target Next 16 / React 19 site.

Quick start (drop-in)

import { InvestorQa, InvestorQaStyles } from "podhouse-investor-qa";

export default function QaClient({ payload }) {
  return (
    <>
      <InvestorQaStyles />        {/* or ship your own CSS against the .iqa-* hooks */}
      <InvestorQa payload={payload} options={{ featured: ["S1-Q01", "S3-Q01"] }} />
    </>
  );
}

payload is null while loading — the engine idles until it arrives.

Wiring the data (host side)

The payload comes from your dataUrl route. Fetch our API server-side with the per-site secret (never in the browser):

// app/api/investor-qa/route.ts  (or your existing dataUrl route)
export async function GET() {
  const res = await fetch(`${process.env.IQA_API_BASE}/investor-qa/${PROJECT}`, {
    headers: {
      Authorization: "Basic " + Buffer
        .from(`${process.env.IQA_SITE_ID}:${process.env.IQA_SITE_SECRET}`)
        .toString("base64"),
    },
    cache: "no-store", // the HLS URL is short-lived / re-signed
  });
  return new Response(await res.text(), {
    headers: { "content-type": "application/json" },
  });
}

Then fetch that route from the client and hand the JSON to <InvestorQa payload={...} />.

Composing the pieces

Prefer full control? Drive the hook and place the granular components yourself:

import {
  useInvestorQa, Search, Player, AnswerText, Browse,
} from "podhouse-investor-qa";

function Qa({ payload }) {
  const engine = useInvestorQa(payload, undefined, { featured: ["S1-Q01"] });
  return (
    <div className="iqa-root">
      <Search engine={engine} placeholder="Ask about occupancy, taxes, risk…" />
      <Player engine={engine} />       {/* audio-only "♪ Listen / ▶︎ Watch" toggle is built in */}
      <AnswerText engine={engine} />   {/* collapsible full text answer under the player */}
      <Browse engine={engine} />
    </div>
  );
}

The drop-in <InvestorQa> renders Search + Player + Browse; add <AnswerText> yourself (as above) when you want the text-answer panel.

useInvestorQa(payload, videoRef?, options?) returns { state, controls, refs }:

  • state — ready, title, chapters, sections, featured, activeChapter, mode (idle | card | clip), layout, cardVisible, cardLabel, isPlaying, isFullscreen, audioOnly, query, suggestions, activeSuggestion.
  • controls — play(id), togglePlay(), seek(fraction01), enterFullscreen(), exitFullscreen(), toggleFullscreen(), setAudioOnly(on), toggleAudioOnly(), setQuery(q), moveSuggestion(±1), chooseActiveSuggestion().
  • refs — the DOM bindings the default Player/Callout/Captions attach; attach a subset if you build a custom stage.

The email gate

Soft-gate the Q&A for lead capture: viewers watch freeAnswers distinct answers, then the next new answer raises a prompt instead of playing. One email unlocks everything, and both the watch count and the unlock persist in localStorage (per browser — this is lead-capture friction on public content, not access control; the media URL is unchanged). Replays of already-watched answers are never blocked, and dismissing the prompt just returns the viewer to browsing.

const engine = useInvestorQa(payload, undefined, {
  gate: {
    freeAnswers: 3,
    collectName: true, // the card asks for a full name beside the email
    // Your capture endpoint — the engine never sends anything anywhere itself.
    // info = { name, answersWatched } — the watch flow as QUESTION TEXTS, in the
    // order first picked, persisted across sessions.
    onSubmit: async (email, info) => {
      const res = await fetch("/lead", {
        method: "POST",
        headers: { "content-type": "application/json" },
        body: JSON.stringify({
          email, form: "qa-gate",
          answers: { name: info.name, answersWatched: info.answersWatched },
        }),
      });
      return res.ok; // false / throw keeps the gate up with an error state
    },
    // After the unlock, each new distinct answer reports the full flow — keep
    // the CRM record current however you like.
    onProgress: (answersWatched) => { /* re-post, debounced, etc. */ },
  },
});
// <Gate engine={engine} /> — the default prompt (the <InvestorQa> drop-in
// renders it automatically when options.gate is set)

The watch flow is also live state — state.questionsWatched (question texts, watch order, persisted) — so a custom gate card can read it directly instead of tracking plays itself.

  • <Gate engine={engine} /> — a themed modal (kicker / heading / sub / email + CTA / dismiss, all overridable via props). Class hooks: .iqa-gate, .iqa-gate-backdrop, .iqa-gate-card, .iqa-gate-kick, .iqa-gate-h, .iqa-gate-sub, .iqa-gate-row, .iqa-gate-input, .iqa-gate-cta, .iqa-gate-err, .iqa-gate-dismiss.
  • Custom UI: read state.gateVisible / state.gateUnlocked / state.answersWatched; call controls.submitGate(email) (validates, runs onSubmit, unlocks and resumes the blocked answer) or do your own capture and call controls.unlock(); controls.closeGate() dismisses.
  • Options: storageKey (default "iqa-gate") to scope persistence, persist: false for session-only counting.

Text answer & audio-only mode

Two extras beyond the player, both driven by the same engine:

  • <AnswerText engine={engine} /> — a collapsible, readable transcript of the active question's full answer, shown under the player. Renders nothing until an answer is picked. Props: readLabel / hideLabel (toggle text), defaultOpen (default true). Class hooks: .iqa-answer, .iqa-answer-toggle, .iqa-answer-body, .iqa-answer-q, .iqa-answer-text.
  • Audio-only mode — <Player> shows a "♪ Listen / ▶︎ Watch" toggle once an answer is active; in audio mode the video stays audible but the stage is replaced by a compact audio face (captions keep flowing). Turn the button off with <Player audio={false} />, or relabel via listenLabel / watchLabel. Drive it yourself with controls.toggleAudioOnly() / controls.setAudioOnly(on) and read state.audioOnly. Class hooks: .iqa-modebtn, .iqa-audio (on .iqa-player), .iqa-audioface, .iqa-audioface-icon, .iqa-audioface-q.

High-frequency work (caption text, seek value, callout reveal, play glyph) is written imperatively to refs and never re-renders React; only real transitions touch state.

B-roll (0.4+)

A visual with kind: "broll" carries a short muted clip (media.url) instead of a slideSpec. Over its region the engine swaps the speaker's picture for the clip — full frame, the split slot, or the PiP, whatever the current callout layout puts the speaker in — while the answer's audio, captions and callouts continue. media.sourceIn lets a visual play a window of a longer file (the engine pre-seeks there while buffering). Clips are buffered ahead (the first one when the answer starts, the next during each fade-out), kept loosely in sync with the master, and skipped if not ready in time; a clip that errors is dropped for the session. The default Player mounts the <Broll> element; custom stages place <Broll engine={engine} /> right after the master video (omit it to ignore B-roll). state.brollActive reports when a clip is on screen; the stage carries the iqa-broll-on class.

Theming

Everything is scoped under .iqa-root and driven by CSS variables. Override the tokens on your own wrapper — or restyle the .iqa-* class hooks wholesale:

.iqa-root {
  --iqa-bg: #0b1220;
  --iqa-panel-bg: #131c2e;
  --iqa-ink: #eaf0ff;
  --iqa-mut: #9fb0cc;
  --iqa-acc: #5b8cff;
  --iqa-acc2: #3f6ae0;
  --iqa-line: #24304a;
  --iqa-field: #ffffff;
  --iqa-field-ink: #16233d;
  --iqa-serif: "Playfair Display", Georgia, serif;
  --iqa-sans: "Inter", system-ui, sans-serif;
  --iqa-radius: 18px;
}

Per-speaker theming: the question-number badge carries data-speaker="<speaker>", so .iqa-qnum[data-speaker="founder-a"] { background: … } colors it.

Import the raw CSS string with import { investorQaStyles } from "podhouse-investor-qa" if you'd rather inject it yourself.

Fixtures

import { fixturePayload } from "podhouse-investor-qa/fixtures";

A brand-neutral payload in the exact contract shape (5 answers across 3 sections, three callouts, sample captions). media.hlsUrl is a placeholder — point it at a real signed HLS playlist to see playback.

The data contract

type InvestorQaPayload = {
  project:  { title: string; sectionTitles: Record<number, string> };
  chapters: { id; section; qnum; speaker; question; answer; startTime; endTime; order }[];
  visuals:  { chapterId; layout; region: { startTime; endTime }; slideSpec }[];
  captions: { s; e; w; ci }[];
  media:    { hlsUrl: string };
};

All contract types are exported (InvestorQaPayload, Chapter, Visual, Caption, SlideSpec, Layout, …).