diff --git a/fern/components/index.tsx b/fern/components/index.tsx
new file mode 100644
index 0000000000..289efdaf69
--- /dev/null
+++ b/fern/components/index.tsx
@@ -0,0 +1,8 @@
+// Barrel for docs MDX components. Each component lives in its own subdirectory as an index.tsx
+// for organization. MDX pages import this barrel as a file path —
+// `import { VoiceWidget } from "@/components/index"` — because Fern's resolver only resolves file
+// paths, not directories (and not directory→index). For the same reason the re-exports below use
+// explicit `/index` file paths rather than bare directory paths. `export *` pulls in each
+// component's public surface (components + types) without enumerating them.
+export * from "./voice-widget/index";
+export * from "./skeleton/index";
diff --git a/fern/components/skeleton/index.tsx b/fern/components/skeleton/index.tsx
new file mode 100644
index 0000000000..c59f770bdc
--- /dev/null
+++ b/fern/components/skeleton/index.tsx
@@ -0,0 +1,50 @@
+import type { CSSProperties } from "react";
+
+// Reusable loading-skeleton primitive for custom MDX components that fetch data at runtime.
+// Compose a few of these to mimic the shape of the content that's loading. Pairs with this folder's
+// styles.css (loaded via docs.yml `css:` — Fern's component bundler doesn't process CSS imports).
+// Theme-aware (light/dark) through Fern's grayscale vars.
+//
+// import { Skeleton, SkeletonText } from "@/components/index";
+// if (!data) return ;
+
+export interface SkeletonProps {
+ /** CSS width — number (px) or any CSS length (e.g. "60%"). */
+ width?: string | number;
+ /** CSS height — number (px) or any CSS length. Default: 14. */
+ height?: string | number;
+ /** Border radius override — number (px) or any CSS length (e.g. "50%" for a circle). */
+ radius?: string | number;
+ className?: string;
+ style?: CSSProperties;
+}
+
+export function Skeleton({ width, height = 14, radius, className, style }: SkeletonProps) {
+ return (
+
+ );
+}
+
+export interface SkeletonTextProps {
+ /** Number of lines. Default: 3. */
+ lines?: number;
+ /** Gap between lines in px. Default: 8. */
+ gap?: number;
+ /** Per-line widths; defaults to full width with a shorter last line. */
+ widths?: (string | number)[];
+}
+
+/** A stack of text-line skeletons — for paragraph/description placeholders. */
+export function SkeletonText({ lines = 3, gap = 8, widths }: SkeletonTextProps) {
+ return (
+
+ );
+}
diff --git a/fern/components/skeleton/styles.css b/fern/components/skeleton/styles.css
new file mode 100644
index 0000000000..0f50a8d170
--- /dev/null
+++ b/fern/components/skeleton/styles.css
@@ -0,0 +1,25 @@
+/* Reusable loading-skeleton (shimmer) for custom MDX components, ported from the SignalWire
+ components design reference. The original drove it with --skeleton-base / --skeleton-shimmer
+ design tokens; here those map onto Fern's theme-aware grayscale vars so it follows light/dark.
+ Loaded globally via docs.yml `css:`. Markup comes from this folder's index.tsx (`.sw-skeleton`). */
+.sw-skeleton {
+ background: var(--grayscale-a3);
+ border-radius: 8px;
+ position: relative;
+ overflow: hidden;
+ flex: none; /* keep its size in flex layouts */
+}
+.sw-skeleton::after {
+ content: "";
+ position: absolute;
+ inset: 0;
+ background: linear-gradient(90deg, transparent, var(--grayscale-a4), transparent);
+ animation: sw-shimmer 1.5s infinite;
+}
+@keyframes sw-shimmer {
+ 0% { transform: translateX(-100%); }
+ 100% { transform: translateX(100%); }
+}
+@media (prefers-reduced-motion: reduce) {
+ .sw-skeleton::after { animation: none; }
+}
diff --git a/fern/components/tsconfig.json b/fern/components/tsconfig.json
new file mode 100644
index 0000000000..83fe9db633
--- /dev/null
+++ b/fern/components/tsconfig.json
@@ -0,0 +1,19 @@
+{
+ // Local tooling only (editor + LSP type-checking of the custom MDX components). Fern owns the
+ // production build and provides React at build time, so React lives in the root package.json's
+ // devDependencies — these settings just teach tsserver how to resolve the JSX runtime and types.
+ "compilerOptions": {
+ "target": "ES2020",
+ "lib": ["ES2020", "DOM", "DOM.Iterable"],
+ "module": "ESNext",
+ "moduleResolution": "Bundler",
+ "jsx": "react-jsx",
+ "strict": true,
+ "noEmit": true,
+ "skipLibCheck": true,
+ "esModuleInterop": true,
+ "resolveJsonModule": true,
+ "types": ["react"]
+ },
+ "include": ["**/*.ts", "**/*.tsx"]
+}
diff --git a/fern/components/voice-widget/index.tsx b/fern/components/voice-widget/index.tsx
new file mode 100644
index 0000000000..fef88d3c57
--- /dev/null
+++ b/fern/components/voice-widget/index.tsx
@@ -0,0 +1,503 @@
+import { memo, useCallback, useDeferredValue, useEffect, useMemo, useRef, useState } from "react";
+import type { CSSProperties } from "react";
+import type { Catalog, Manifest, VoiceRow } from "./types";
+import { Skeleton } from "../skeleton/index";
+
+// Ported from @signalwire/tts-voice-widget for embedding as a Fern MDX component.
+// Pure data consumer: fetches catalog.json + manifest.json produced by the TTS pipeline and plays
+// the pre-synthesized sample clips. Styles live in this folder's styles.css, loaded via docs.yml
+// `css:` (Fern's component bundler does not process CSS imports).
+
+// Where the hosted asset bundle lives. The bundle cannot live in fern/assets — Fern only serves
+// assets it statically discovers in MDX/CSS and rewrites to content-hashed URLs, so a runtime
+// fetch() has no resolvable path. It must be served from an external origin with CORS enabled.
+//
+// On the production CDN the JSON and the audio/ tree sit under different prefixes: catalog.json +
+// manifest.json are served from /voice_widget/dist/, while the audio is served from the domain root
+// (/audio//.mp3). The manifest stores each clip's `audio` as a path relative to
+// AUDIO_BASE (e.g. "audio/rime/zion.mp3"), so the two bases are independent — keep each in sync with
+// the CDN layout. For local dev against `http-server temp/voice_widget/dist -p 8080 --cors`, set
+// both to "http://localhost:8080".
+const ASSET_BASE = "https://mcdn.signalwire.com/voice_widget/dist"; // catalog.json + manifest.json
+const AUDIO_BASE = "https://mcdn.signalwire.com"; // /audio//.mp3
+
+const ALL = "__all__";
+const DEFAULT_PAGE_SIZE = 48;
+const MOBILE_PAGE_SIZE = 12;
+const MOBILE_QUERY = "(max-width: 640px)";
+// Multiples of the max column count (4) so every page fills complete rows; 4 = a single row.
+const PAGE_SIZE_OPTIONS = [4, 8, 12, 24, 48, 96];
+
+type FilterKey = "search" | "provider" | "language" | "gender" | "group" | "pageSize";
+
+// Client-side row: a catalog voice joined with its clip, plus two precomputed fields — `_search`,
+// a lowercased blob so the per-keystroke filter runs one substring test per row instead of
+// re-lowercasing 4–5 fields, and `_uid`, a collision-free identity for React keys and play state
+// (assigned in loadBundle; the catalog's key+model is not unique on its own).
+type Row = VoiceRow & { _search: string; _uid: string };
+
+// The asset bundle (catalog.json + manifest.json) is one large artifact (~5 MB, ~4.9k voices)
+// shared by every TTS page that embeds the widget. Cache the parsed+joined rows per URL pair so
+// navigating across the index and the provider pages fetches and parses it once per session, not
+// once per mount. Each embed then narrows this shared set down to the voices it shows (see baseRows).
+const bundleCache = new Map>();
+
+function loadBundle(catalogUrl: string, manifestUrl: string): Promise {
+ const cacheKey = `${catalogUrl}\n${manifestUrl}`;
+ let cached = bundleCache.get(cacheKey);
+ if (!cached) {
+ cached = Promise.all([
+ // Check r.ok before parsing: a 404/500 from the CDN is an HTML page, and r.json() on it
+ // surfaces as `SyntaxError: Unexpected token '<'` in the error UI instead of the real cause.
+ fetch(catalogUrl).then((r) => {
+ if (!r.ok) throw new Error(`HTTP ${r.status} fetching catalog.json`);
+ return r.json() as Promise;
+ }),
+ fetch(manifestUrl).then((r) => r.json() as Promise).catch(() => ({ clips: {} } as Manifest)),
+ ]).then(([cat, man]) => {
+ // Row identity: key+model is *almost* unique, but the production catalog repeats it for a
+ // handful of rows (rime voices listed once per language, plus two literal duplicate rows),
+ // so suffix a counter on collision. Assigned once here so it's stable for the session:
+ // using `_uid` as the React key lets a card survive filter/page changes (the memoized Card
+ // skips re-rendering), and play state highlights exactly one card even for collisions.
+ const seen = new Map();
+ return cat.voices.map((v) => {
+ const base = modelKeyOf(v);
+ const n = seen.get(base) ?? 0;
+ seen.set(base, n + 1);
+ return {
+ ...v,
+ clip: man.clips?.[v.key],
+ _uid: n ? `${base}#${n}` : base,
+ // Mirrors the fields the search filter below tests against (name, provider, description, tags).
+ _search: `${v.display_name} ${v.provider} ${v.description} ${v.tags.join(" ")}`.toLowerCase(),
+ };
+ });
+ });
+ // Never cache a rejected fetch — evict so a later mount can retry instead of replaying the error.
+ cached.catch(() => bundleCache.delete(cacheKey));
+ bundleCache.set(cacheKey, cached);
+ }
+ return cached;
+}
+
+// Tracks whether the viewport is in the mobile breakpoint (matches the CSS media query below).
+function useIsMobile() {
+ const [mobile, setMobile] = useState(false);
+ useEffect(() => {
+ if (typeof window === "undefined" || !window.matchMedia) return;
+ const mq = window.matchMedia(MOBILE_QUERY);
+ const update = () => setMobile(mq.matches);
+ update();
+ mq.addEventListener("change", update);
+ return () => mq.removeEventListener("change", update);
+ }, []);
+ return mobile;
+}
+
+export interface VoiceWidgetProps {
+ /** Base URL of the hosted bundle (serves catalog.json, manifest.json, audio/). Default: ASSET_BASE. */
+ assetBaseUrl?: string;
+ /** Override the catalog.json URL. Default: `${assetBaseUrl}/catalog.json`. */
+ catalogUrl?: string;
+ /** Override the manifest.json URL. Default: `${assetBaseUrl}/manifest.json`. */
+ manifestUrl?: string;
+ /** Override the base the clip `audio` paths resolve against. Default: AUDIO_BASE. */
+ audioBaseUrl?: string;
+ /** Initial grouping. "none" renders a flat grid with no section headers or group toggle. Default: "provider". */
+ groupBy?: "provider" | "language" | "none";
+ /** Cards rendered per page (page-size limit). Default: 48. Keeps the DOM small and the catalog fast. */
+ pageSize?: number;
+ /**
+ * Fixed number of grid columns. Default: 3. Collapses to 1 column on phones regardless. Pass 0
+ * for a responsive auto-fill grid (≈240px min per card) instead of a fixed count.
+ */
+ columns?: number;
+ /**
+ * Lock the widget to a single provider. Matches the provider label or engine id,
+ * case-insensitive (e.g. "ElevenLabs" or "elevenlabs"). When set, only that provider's voices
+ * are shown and the provider filter is hidden — for embedding on a provider-specific page.
+ */
+ provider?: string;
+ /**
+ * Show only these specific voices (an allowlist). Each entry matches a voice's `voice_id`, its
+ * `/` key, its `/:` key (to disambiguate a voice that
+ * exists under multiple models), or its display name — case-insensitive. When unset, all voices
+ * show. Use to curate a small demo set, e.g. one representative voice per provider.
+ */
+ voiceIds?: string[];
+ /**
+ * Toggle the filter/search controls. `true` or unset shows all; `false` hides all (just the
+ * title + grid). An object toggles individual controls — `search`, `provider`, `language`,
+ * `gender`, `group`, `pageSize` — each defaulting on unless set `false`. The provider control is
+ * always hidden when the `provider` lock prop is set.
+ */
+ filters?: boolean | Partial>;
+}
+
+export function VoiceWidget({
+ assetBaseUrl = ASSET_BASE,
+ catalogUrl = `${assetBaseUrl}/catalog.json`,
+ manifestUrl = `${assetBaseUrl}/manifest.json`,
+ audioBaseUrl = AUDIO_BASE,
+ groupBy: initialGroup = "provider",
+ pageSize = DEFAULT_PAGE_SIZE,
+ columns = 3,
+ provider: lockedProvider,
+ voiceIds,
+ filters,
+}: VoiceWidgetProps) {
+ // Normalized single-provider lock (null when the widget shows all providers).
+ const lock = lockedProvider?.trim().toLowerCase() || null;
+ // Normalized voice-id allowlist (null when showing all voices). Sorted+joined into a stable key
+ // so the memoized Set below — and the filter that depends on it — don't churn each render.
+ const voiceIdsKey = voiceIds && voiceIds.length
+ ? voiceIds.map((s) => s.trim().toLowerCase()).filter(Boolean).sort().join("|")
+ : "";
+ const idAllowlist = useMemo(
+ () => (voiceIdsKey ? new Set(voiceIdsKey.split("|")) : null),
+ [voiceIdsKey]
+ );
+ // Fixed column count overrides the responsive auto-fill grid when provided. Set as CSS custom
+ // properties (consumed by .vw-grid in the stylesheet) rather than grid-template-columns directly,
+ // so the mobile media query can still collapse the grid to one column regardless of this prop.
+ const gridStyle = (columns && columns > 0
+ ? { "--vw-cols": Math.floor(columns), "--vw-col-min": "0px" }
+ : undefined) as CSSProperties | undefined;
+ const [allRows, setAllRows] = useState(null);
+ const [error, setError] = useState(null);
+ const [q, setQ] = useState("");
+ const [provider, setProvider] = useState(ALL);
+ const [language, setLanguage] = useState(ALL);
+ const [gender, setGender] = useState(ALL);
+ const [group, setGroup] = useState<"provider" | "language" | "none">(initialGroup);
+ const [page, setPage] = useState(0);
+ const [pageSizeChoice, setPageSizeChoice] = useState(pageSize);
+ const audioRef = useRef(null);
+ const [playingKey, setPlayingKey] = useState(null);
+
+ // Defer the search term so typing stays responsive while the (cheap) filter recomputes.
+ const deferredQ = useDeferredValue(q);
+
+ // On small screens the grid collapses to one column (CSS), so cap the page size too — a 60-card
+ // single column is an excessive scroll on a phone.
+ const isMobile = useIsMobile();
+ const effectivePageSize = isMobile ? Math.min(pageSizeChoice, MOBILE_PAGE_SIZE) : pageSizeChoice;
+ const pageSizeOpts = [...new Set([...PAGE_SIZE_OPTIONS, pageSize])].sort((a, b) => a - b);
+
+ // Resolve which filter/search controls are visible. `filters` is a boolean (all on/off) or a
+ // per-control object; each control defaults on unless explicitly false.
+ const showFilter = (key: FilterKey) =>
+ filters === false ? false
+ : filters && typeof filters === "object" ? filters[key] !== false
+ : true;
+ const showProvider = !lock && showFilter("provider");
+ const showGroup = group !== "none" && showFilter("group");
+
+ useEffect(() => {
+ let alive = true;
+ loadBundle(catalogUrl, manifestUrl)
+ .then((loaded) => { if (alive) setAllRows(loaded); })
+ .catch((e) => { if (alive) setError(String(e)); });
+ return () => { alive = false; };
+ }, [catalogUrl, manifestUrl]);
+
+ // Static narrowing: the allowlist (voiceIds) and provider lock can't change at runtime, so apply
+ // them once here instead of re-checking the full catalog inside the per-keystroke filter below.
+ // Everything downstream — dropdown options, search, grouping, pagination — then works over only
+ // the voices this embed can ever show (12 on the index, one provider's voices on a provider page).
+ const baseRows = useMemo(() => {
+ if (!allRows) return null;
+ if (!idAllowlist && !lock) return allRows;
+ return allRows.filter((r) =>
+ (!idAllowlist ||
+ idAllowlist.has(r.voice_id.toLowerCase()) ||
+ idAllowlist.has(r.key.toLowerCase()) ||
+ idAllowlist.has(modelKeyOf(r).toLowerCase()) ||
+ idAllowlist.has(r.display_name.toLowerCase())) &&
+ (!lock || r.provider.toLowerCase() === lock || r.engine.toLowerCase() === lock));
+ }, [allRows, idAllowlist, lock]);
+
+ // Dropdown options derive from the narrowed set, so a provider-locked page lists only that
+ // provider's languages, not all ~180 in the catalog.
+ const providers = useMemo(() => uniq(baseRows?.map((r) => r.provider)), [baseRows]);
+ const languages = useMemo(() => uniq(baseRows?.flatMap((r) => r.languages)), [baseRows]);
+
+ const filtered = useMemo(() => {
+ if (!baseRows) return [];
+ const needle = deferredQ.trim().toLowerCase();
+ return baseRows.filter((r) =>
+ (provider === ALL || r.provider === provider) &&
+ (language === ALL || r.languages.includes(language)) &&
+ (gender === ALL || r.gender === gender) &&
+ (!needle || r._search.includes(needle)));
+ }, [baseRows, deferredQ, provider, language, gender]);
+
+ // Flatten into grouped order once, and remember each group's full size for the section headers.
+ // group === "none" keeps the filtered order as-is, with no sections.
+ const { ordered, groupTotals } = useMemo(() => {
+ if (group === "none") return { ordered: filtered, groupTotals: new Map() };
+ const m = new Map();
+ for (const r of filtered) {
+ const k = group === "provider" ? r.provider : r.primary_language;
+ (m.get(k) ?? m.set(k, []).get(k)!).push(r);
+ }
+ const entries = [...m.entries()].sort((a, b) => a[0].localeCompare(b[0]));
+ const ordered: Row[] = [];
+ const groupTotals = new Map();
+ for (const [name, items] of entries) {
+ groupTotals.set(name, items.length);
+ for (const it of items) ordered.push(it);
+ }
+ return { ordered, groupTotals };
+ }, [filtered, group]);
+
+ // Reset to the first page whenever the result set changes — done during render (not in an
+ // effect) so there's no one-frame flash of the old page number. Canonical "adjust state on
+ // change" pattern: the setState below re-renders synchronously before commit.
+ const filterSig = `${voiceIdsKey}|${lock}|${deferredQ}|${provider}|${language}|${gender}|${group}|${pageSizeChoice}`;
+ const [lastSig, setLastSig] = useState(filterSig);
+ if (filterSig !== lastSig) {
+ setLastSig(filterSig);
+ if (page !== 0) setPage(0);
+ }
+
+ // Pagination math. Clamp the page so it stays valid even mid-transition.
+ const pageCount = Math.max(1, Math.ceil(ordered.length / effectivePageSize));
+ const safePage = Math.min(page, pageCount - 1);
+ const start = safePage * effectivePageSize;
+ const end = Math.min(start + effectivePageSize, ordered.length);
+
+ // Split the current page's slice into contiguous group sections (reuses the section/grid DOM).
+ // group === "none" → a single unlabeled section (flat grid).
+ const pageSections = useMemo(() => {
+ const slice = ordered.slice(start, end);
+ if (group === "none") return slice.length ? [{ name: "", items: slice }] : [];
+ const secs: { name: string; items: Row[] }[] = [];
+ for (const r of slice) {
+ const k = group === "provider" ? r.provider : r.primary_language;
+ const last = secs[secs.length - 1];
+ if (last && last.name === k) last.items.push(r);
+ else secs.push({ name: k, items: [r] });
+ }
+ return secs;
+ }, [ordered, start, end, group]);
+
+ // Keep the latest playing key in a ref so play() can stay referentially stable (memoized cards).
+ const playingKeyRef = useRef(null);
+ useEffect(() => { playingKeyRef.current = playingKey; }, [playingKey]);
+
+ const play = useCallback((r: Row) => {
+ if (!r.clip || r.clip.status !== "ok") return;
+ const a = audioRef.current!;
+ if (playingKeyRef.current === r._uid && !a.paused) {
+ a.pause();
+ playingKeyRef.current = null;
+ setPlayingKey(null);
+ return;
+ }
+ a.src = audioBaseUrl ? `${audioBaseUrl.replace(/\/$/, "")}/${r.clip.audio}` : r.clip.audio;
+ // Mark playing immediately (and synchronously in the ref), not when play() resolves: with
+ // preload="none" the clip has to download first, and during that window a second click must
+ // read this card as already playing so it pauses — otherwise it falls through to `a.src = url`,
+ // which re-runs the load algorithm and restarts the download instead of stopping it. The eager
+ // icon flip also gives the click immediate feedback while the clip loads.
+ playingKeyRef.current = r._uid;
+ setPlayingKey(r._uid);
+ a.play().catch(() => {
+ // Rejected (autoplay policy) or aborted by a newer click — only unwind if still current.
+ if (playingKeyRef.current !== r._uid) return;
+ playingKeyRef.current = null;
+ setPlayingKey(null);
+ });
+ }, [audioBaseUrl]);
+
+ const copyConfig = useCallback(async (r: VoiceRow) => {
+ const cfg: Record = { engine: r.engine, voice: r.display_name };
+ if (r.model) cfg.params = { model: r.model };
+ try {
+ await navigator.clipboard.writeText(JSON.stringify(cfg));
+ return true;
+ } catch {
+ return false;
+ }
+ }, []);
+
+ if (error) return
Failed to load voices: {error}
;
+ if (!baseRows) return ;
+
+ // Step from safePage, not the raw page state: `page` can be stale-high after effectivePageSize
+ // grows (a mobile→desktop resize isn't in filterSig), and stepping from it makes Prev appear
+ // dead until the raw value catches back up with the clamped one.
+ const pager = pageCount > 1 ? (
+
+ ) : null;
+
+ return (
+
+
+ );
+}
+
+// Memoized so that playing/pausing a voice (which re-renders the widget) only re-renders the two
+// cards whose `playing` flag actually changed — not all ~60 cards on the page.
+const Card = memo(function Card({ r, playing, onPlay, onCopy }: {
+ r: Row; playing: boolean; onPlay: (r: Row) => void; onCopy: (r: VoiceRow) => Promise | void;
+}) {
+ const disabled = !r.clip || r.clip.status !== "ok";
+ const [copied, setCopied] = useState(false);
+ const copyTimer = useRef>();
+ useEffect(() => () => clearTimeout(copyTimer.current), []);
+
+ const handleCopy = () => {
+ Promise.resolve(onCopy(r)).then((ok) => {
+ if (ok === false) return; // clipboard unavailable — leave the button unchanged
+ setCopied(true);
+ clearTimeout(copyTimer.current);
+ copyTimer.current = setTimeout(() => setCopied(false), 1500);
+ });
+ };
+
+ return (
+
+ {r.provider}
+
{r.clip?.error ?? "no sample (provider key missing)"}
}
+ {/* Both labels are always in the DOM and grid-stacked (see CSS) so the button keeps the width
+ of the wider one — no layout shift when it swaps to the copied state. */}
+
+
+ );
+});
+
+// Loading state: skeleton placeholders that mirror the real header + card grid, so the layout
+// doesn't jump when the data arrives. Built from the shared primitive.
+function VoiceWidgetSkeleton({ gridStyle }: { gridStyle?: CSSProperties }) {
+ return (
+
+
+
+
+
+
+ {Array.from({ length: 8 }).map((_, i) => (
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ))}
+
+
+ );
+}
+
+function Select({ label, value, onChange, opts }:
+ { label: string; value: string; onChange: (v: string) => void; opts: string[] }) {
+ return (
+
+ );
+}
+
+function uniq(xs?: string[]): string[] {
+ return [...new Set((xs ?? []).filter(Boolean))].sort();
+}
+
+// Model-qualified catalog key (/[:]) — the documented matching form for
+// the voiceIds allowlist. NOT unique in the wild: a few rime voices repeat key+model (once per
+// language, plus literal duplicate rows), so row identity uses Row._uid — this plus a collision
+// counter, assigned in loadBundle.
+function modelKeyOf(v: { key: string; model: string | null }): string {
+ return v.model ? `${v.key}:${v.model}` : v.key;
+}
diff --git a/fern/components/voice-widget/styles.css b/fern/components/voice-widget/styles.css
new file mode 100644
index 0000000000..0e811f975e
--- /dev/null
+++ b/fern/components/voice-widget/styles.css
@@ -0,0 +1,90 @@
+/* Voice widget styles, scoped under .vw so it can be dropped into the docs without clobbering host
+ styles. The palette maps onto the docs' theme-aware brand tokens so the widget follows light/dark
+ mode automatically. */
+.vw {
+ --vw-bg: var(--bg-page);
+ --vw-card: var(--bg-surface-raised);
+ --vw-line: var(--border-default);
+ --vw-fg: var(--fg-default);
+ --vw-muted: var(--fg-muted);
+ --vw-accent: var(--accent);
+ --vw-radius: 10px;
+ color: var(--vw-fg); font: 14px/1.45 system-ui, sans-serif;
+}
+.vw-loading, .vw-error { padding: 2rem; color: var(--vw-muted); }
+.vw-error { color: var(--error-text); }
+
+.vw-head { display: flex; align-items: center; gap: 1rem; margin-bottom: .75rem; }
+.vw-title { font-size: 1.15rem; font-weight: 700; }
+.vw-count { color: var(--vw-muted); font-weight: 400; margin-left: .35rem; }
+.vw-search { margin-left: auto; padding: .5rem .75rem; min-width: 220px;
+ background: var(--vw-card); border: 1px solid var(--vw-line); border-radius: var(--vw-radius); color: inherit; }
+
+.vw-filters { display: flex; flex-wrap: wrap; gap: .75rem; align-items: end; margin-bottom: 1rem;
+ position: sticky; top: 0; background: var(--vw-bg); padding: .5rem 0; z-index: 2; }
+.vw-select { display: flex; flex-direction: column; gap: .2rem; font-size: .8rem; color: var(--vw-muted); }
+.vw-select select { padding: .4rem .5rem; background: var(--vw-card); color: var(--vw-fg);
+ border: 1px solid var(--vw-line); border-radius: 8px; }
+.vw-group { display: flex; gap: .25rem; margin-left: auto; }
+.vw-group button { padding: .4rem .6rem; background: var(--vw-card); color: var(--vw-muted);
+ border: 1px solid var(--vw-line); border-radius: 8px; cursor: pointer; }
+.vw-group button.on { color: var(--accent); border-color: var(--accent); background: var(--accent-a3); }
+
+.vw-section { margin-bottom: 1.5rem; }
+.vw-section-title { font-size: .95rem; margin: .5rem 0; text-transform: capitalize; }
+.vw-section-title span { color: var(--vw-muted); font-weight: 400; margin-left: .35rem; }
+
+/* Column count is driven by --vw-cols (set inline by the `columns` prop; falls back to responsive
+ auto-fill). Defining the template here lets the mobile media query override it regardless. */
+.vw-grid { display: grid; gap: .75rem;
+ grid-template-columns: repeat(var(--vw-cols, auto-fill), minmax(var(--vw-col-min, 240px), 1fr)); }
+.vw-card { background: var(--vw-card); border: 1px solid var(--vw-line); border-radius: var(--vw-radius);
+ padding: .75rem; display: flex; flex-direction: column; gap: .5rem; overflow: hidden; }
+.vw-card.vw-disabled { opacity: .55; }
+.vw-card-top { display: flex; align-items: center; gap: .5rem; min-width: 0; }
+.vw-play { width: 32px; height: 32px; flex: 0 0 auto; border-radius: 50%; cursor: pointer;
+ background: var(--vw-accent); color: var(--accent-contrast); border: none; font-size: .8rem; }
+.vw-play:disabled { background: var(--grayscale-a4); color: var(--vw-muted); cursor: not-allowed; }
+.vw-name { font-weight: 600; flex: 1 1 auto;
+ min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+/* Provider badge sits in the card's top-right corner, on its own line above the title, tucked into
+ the corner so it can't overlap the title or meta chips and adds no tall empty strip. */
+.vw-badge { font-size: .68rem; padding: .15rem .4rem; border: 1px solid var(--vw-line);
+ border-radius: 999px; color: var(--vw-muted); white-space: nowrap;
+ align-self: flex-end; width: fit-content; max-width: 100%;
+ overflow: hidden; text-overflow: ellipsis; margin: -0.15rem -0.15rem -0.7rem 0; }
+.vw-meta { display: flex; flex-wrap: wrap; gap: .35rem; font-size: .7rem; color: var(--vw-muted); }
+.vw-meta span { padding: .12rem .4rem; background: var(--grayscale-a3); border-radius: 6px; }
+.vw-gender.vw-male { color: #3b82f6; } .vw-gender.vw-female { color: #d6409f; }
+.vw-sample { margin: 0; font-size: .82rem; color: var(--fg-secondary); font-style: italic; }
+.vw-note { margin: 0; font-size: .72rem; color: var(--warning-text); }
+/* margin-top:auto pins copy to the bottom so it aligns across a row regardless of sample length.
+ The two labels are grid-stacked in one cell so the button always sizes to the wider label
+ ("copy config") — swapping to the copied state causes no width change / layout shift. */
+.vw-copy { align-self: flex-start; margin-top: auto; font-size: .72rem; background: transparent;
+ color: var(--vw-accent); border: 1px solid var(--vw-line); border-radius: 6px; padding: .25rem .5rem;
+ cursor: pointer; display: inline-grid; text-align: center; transition: border-color .15s ease; }
+.vw-copy:hover:not(.vw-copied) { border-color: var(--accent); }
+.vw-copy > span { grid-area: 1 / 1; }
+.vw-copy .vw-copy-done { visibility: hidden; } /* reserves space but hidden until copied */
+.vw-copy.vw-copied .vw-copy-default { visibility: hidden; }
+.vw-copy.vw-copied .vw-copy-done { visibility: visible; }
+.vw-copy.vw-copied { color: #16a34a; border-color: #16a34a; } /* green check, readable on light + dark */
+
+/* Pagination controls + empty state. */
+.vw-pager { display: flex; align-items: center; justify-content: center; gap: .75rem;
+ margin: 1rem 0; flex-wrap: wrap; }
+.vw-pager-btn { padding: .4rem .8rem; background: var(--grayscale-2); color: var(--fg-default);
+ border: 1px solid var(--border-default); border-radius: 8px; cursor: pointer; font-size: .82rem; }
+.vw-pager-btn:hover:not(:disabled) { border-color: var(--accent); color: var(--accent); }
+.vw-pager-btn:disabled { opacity: .45; cursor: not-allowed; }
+.vw-pager-info { font-size: .8rem; color: var(--fg-muted); }
+.vw-pager-page { color: var(--fg-muted); }
+.vw-empty { padding: 2rem 0; color: var(--fg-muted); font-size: .9rem; }
+
+/* On phones, collapse to a single column no matter what `columns` was set to. */
+@media (max-width: 640px) {
+ .vw-grid { grid-template-columns: 1fr; }
+ .vw-filters { gap: .5rem; }
+ .vw-search { min-width: 0; }
+}
diff --git a/fern/components/voice-widget/types.ts b/fern/components/voice-widget/types.ts
new file mode 100644
index 0000000000..85b0f336c8
--- /dev/null
+++ b/fern/components/voice-widget/types.ts
@@ -0,0 +1,51 @@
+// Shapes of the assets produced by the Python pipeline (tools/voice_widget). These two files +
+// the audio/ tree are the entire contract handed to devex — keep this in sync with
+// catalog_schema.json and synth_examples.py.
+
+export type Gender = "male" | "female" | "neutral" | "unknown";
+
+export interface CatalogVoice {
+ key: string; // "/" — unique
+ engine: string; // mod_openai engine id (elevenlabs, cartesia, ...)
+ provider: string; // human-facing label
+ voice_id: string; // value passed to the provider to synthesize
+ display_name: string; // mod_openai `voice` field / UI label
+ languages: string[];
+ primary_language: string;
+ gender: Gender;
+ model: string | null;
+ tags: string[];
+ description: string;
+ provider_preview_url: string | null;
+ source: "api" | "static" | "docs";
+ fetched_at: string;
+}
+
+export interface Catalog {
+ generated_at: string;
+ voices: CatalogVoice[];
+}
+
+export interface Clip {
+ language: string;
+ sample_text: string;
+ audio: string; // path relative to the audio base (e.g. "audio/elevenlabs/Brian.mp3")
+ format: string;
+ bytes: number;
+ duration_sec: number | null;
+ synth_model: string | null;
+ fingerprint: string | null;
+ synthesized_at: string;
+ status: "ok" | "error";
+ error: string | null;
+}
+
+export interface Manifest {
+ generated_at: string;
+ clips: Record; // keyed by CatalogVoice.key
+}
+
+// A catalog voice joined with its synthesized clip (if any).
+export interface VoiceRow extends CatalogVoice {
+ clip?: Clip;
+}
diff --git a/fern/docs.yml b/fern/docs.yml
index f18bcbd0da..b84a7e6a2e 100644
--- a/fern/docs.yml
+++ b/fern/docs.yml
@@ -30,6 +30,8 @@ agents:
experimental:
ai-examples: false
+ mdx-components:
+ - ./components
products:
- display-name: Home
@@ -173,6 +175,8 @@ css:
- brand-tokens.css
- brand-overrides.css
- styles.css
+ - components/skeleton/styles.css
+ - components/voice-widget/styles.css
redirects:
- source: /docs/agents-sdk
diff --git a/fern/products/platform/pages/calling/voice/TTS/azure.mdx b/fern/products/platform/pages/calling/voice/TTS/azure.mdx
index 6ad66c81db..107039ac2c 100644
--- a/fern/products/platform/pages/calling/voice/TTS/azure.mdx
+++ b/fern/products/platform/pages/calling/voice/TTS/azure.mdx
@@ -5,10 +5,16 @@ description: Learn how to use Azure TTS voices on the SignalWire platform.
slug: /voice/tts/azure
---
+import { VoiceWidget } from "@/components/index";
+
Microsoft's Azure platform offers an impressive array of high-quality, multilingual voices in its Neural model.
## Voices
+Press play to audition any Azure voice, then **copy config** to grab the value for SWML or your SDK.
+
+
+
Browse the complete list of Azure Neural voices in Microsoft's official documentation.
diff --git a/fern/products/platform/pages/calling/voice/TTS/cartesia.mdx b/fern/products/platform/pages/calling/voice/TTS/cartesia.mdx
index 78502a2484..b204a000ce 100644
--- a/fern/products/platform/pages/calling/voice/TTS/cartesia.mdx
+++ b/fern/products/platform/pages/calling/voice/TTS/cartesia.mdx
@@ -5,6 +5,8 @@ description: Learn how to use Cartesia TTS voices on the SignalWire platform.
slug: /voice/tts/cartesia
---
+import { VoiceWidget } from "@/components/index";
+
Cartesia offers a wide selection of fully multilingual voices with very low latency.
[Create a Cartesia account](https://play.cartesia.ai) to browse and test voices in the Cartesia Playground.
@@ -34,6 +36,10 @@ All Cartesia voices can be used with any model.
## Voices
+Press play to audition any Cartesia voice, then **copy config** to grab the value for SWML or your SDK.
+
+
+
Copy the voice ID from the table below:
diff --git a/fern/products/platform/pages/calling/voice/TTS/deepgram.mdx b/fern/products/platform/pages/calling/voice/TTS/deepgram.mdx
index 39d162445a..597f25497d 100644
--- a/fern/products/platform/pages/calling/voice/TTS/deepgram.mdx
+++ b/fern/products/platform/pages/calling/voice/TTS/deepgram.mdx
@@ -5,6 +5,8 @@ description: Learn how to use Deepgram TTS voices on the SignalWire platform.
slug: /voice/tts/deepgram
---
+import { VoiceWidget } from "@/components/index";
+
Deepgram's Aura model offers a wide range of voices for its text-to-speech API,
each designed to produce natural-sounding speech output in an array of different accents and speaking styles.
@@ -25,6 +27,10 @@ Deepgram's Aura model provides ultra-low latency text-to-speech optimized for co
## Voices
+Press play to audition any Deepgram voice, then **copy config** to grab the value for SWML or your SDK.
+
+
+
Deepgram Aura voices are designed for natural-sounding English speech. Each voice follows the pattern `aura--en`.
Popular voices include:
diff --git a/fern/products/platform/pages/calling/voice/TTS/elevenlabs.mdx b/fern/products/platform/pages/calling/voice/TTS/elevenlabs.mdx
index d0c4a493d7..29a936f608 100644
--- a/fern/products/platform/pages/calling/voice/TTS/elevenlabs.mdx
+++ b/fern/products/platform/pages/calling/voice/TTS/elevenlabs.mdx
@@ -5,6 +5,8 @@ description: Learn how to use ElevenLabs TTS voices on the SignalWire platform.
slug: /voice/tts/elevenlabs
---
+import { VoiceWidget } from "@/components/index";
+
ElevenLabs voices offer expressive, human-like pronunciation and an extensive list of supported languages.
Every ElevenLabs [voice](#voices) can be used with all of the ElevenLabs [models](#models).
@@ -20,6 +22,10 @@ Consult ElevenLabs' documentation for an up-to-date list of available models and
## Voices
+Press play to audition any ElevenLabs voice, then **copy config** to grab the value for SWML or your SDK.
+
+
+
You can use the **Name** and **ID** values for each voice in the below table interchangeably.
| Name | ID |
diff --git a/fern/products/platform/pages/calling/voice/TTS/google.mdx b/fern/products/platform/pages/calling/voice/TTS/google.mdx
index 88e51b8ac8..dcc080a888 100644
--- a/fern/products/platform/pages/calling/voice/TTS/google.mdx
+++ b/fern/products/platform/pages/calling/voice/TTS/google.mdx
@@ -5,6 +5,8 @@ description: Learn how to use Google Cloud TTS voices on the SignalWire platform
slug: /voice/tts/gcloud
---
+import { VoiceWidget } from "@/components/index";
+
Google Cloud offers a number of robust text-to-speech
[voice models](https://cloud.google.com/text-to-speech/docs/voice-types).
SignalWire supports all Google Cloud voices in both General Availability and Preview
@@ -36,6 +38,12 @@ and prioritizes natural and human-like pronunciation and intonation.
voices have variants in multiple languages. For example, at time of writing,
the `polyglot-1` voice has variants for English (Australia), English (US), French, German, Spanish (Spain), and Spanish (US).
+## Voices
+
+Press play to audition any Google Cloud voice, then **copy config** to grab the value for SWML or your SDK.
+
+
+
## Billing
Google Cloud TTS usage on SignalWire is billed according to the following SKU codes:
diff --git a/fern/products/platform/pages/calling/voice/TTS/index.mdx b/fern/products/platform/pages/calling/voice/TTS/index.mdx
index 41a6549e98..27243370c4 100644
--- a/fern/products/platform/pages/calling/voice/TTS/index.mdx
+++ b/fern/products/platform/pages/calling/voice/TTS/index.mdx
@@ -11,6 +11,8 @@ x-custom:
description: Detailed list of all the TTS providers and voices SignalWire supports.
---
+import { VoiceWidget } from "@/components/index";
+
[polly]: /docs/platform/voice/tts/amazon-polly
[azure]: /docs/platform/voice/tts/azure
@@ -29,6 +31,31 @@ SignalWire integrates natively with leading third-party text-to-speech (TTS) pro
This guide describes supported engines, voices, and languages.
Refer to each provider's documentation for up-to-date model details and service information.
+## Browse and audition voices
+
+Press play to audition one sample voice from each supported provider, and use **copy config** to
+grab the engine and voice values for your SWML or SDK code. Each provider's complete voice list
+lives on its reference page, linked in the table below.
+
+
+
## Compare providers and models
SignalWire's TTS providers offer a wide range of voice engines optimized for various applications.
diff --git a/fern/products/platform/pages/calling/voice/TTS/openai.mdx b/fern/products/platform/pages/calling/voice/TTS/openai.mdx
index a3f8c80264..c8b0b65bd6 100644
--- a/fern/products/platform/pages/calling/voice/TTS/openai.mdx
+++ b/fern/products/platform/pages/calling/voice/TTS/openai.mdx
@@ -5,6 +5,8 @@ description: Learn how to use OpenAI TTS voices on the SignalWire platform.
slug: /voice/tts/openai
---
+import { VoiceWidget } from "@/components/index";
+
OpenAI offers versatile multilingual voices balancing low latency and good quality.
While voices are optimized for English, they perform well across all
[supported languages][languages].
@@ -24,6 +26,10 @@ OpenAI offers two TTS models with different quality and latency characteristics:
## Voices
+Press play to audition any OpenAI voice, then **copy config** to grab the value for SWML or your SDK.
+
+
+
OpenAI provides 6 fully multilingual voices optimized for natural-sounding speech:
| Voice | Description |
diff --git a/fern/products/platform/pages/calling/voice/TTS/polly.mdx b/fern/products/platform/pages/calling/voice/TTS/polly.mdx
index a499fda515..87901575a6 100644
--- a/fern/products/platform/pages/calling/voice/TTS/polly.mdx
+++ b/fern/products/platform/pages/calling/voice/TTS/polly.mdx
@@ -5,6 +5,8 @@ description: Learn how to use Polly TTS voices on the SignalWire platform.
slug: /voice/tts/amazon-polly
---
+import { VoiceWidget } from "@/components/index";
+
Amazon Web Services' Polly TTS engine includes several models to accommodate different use cases.
## Models
@@ -33,6 +35,12 @@ SignalWire supports the following three Amazon models.
+## Voices
+
+Press play to audition any Amazon Polly voice, then **copy config** to grab the value for SWML or your SDK.
+
+
+
## Languages
Consult AWS documentation for a comprehensive and up-to-date list of supported voices,
diff --git a/fern/products/platform/pages/calling/voice/TTS/rime.mdx b/fern/products/platform/pages/calling/voice/TTS/rime.mdx
index 064486efc1..3d8cdfc46d 100644
--- a/fern/products/platform/pages/calling/voice/TTS/rime.mdx
+++ b/fern/products/platform/pages/calling/voice/TTS/rime.mdx
@@ -6,6 +6,8 @@ slug: /voice/tts/rime
description: Learn how to use Rime's Arcana and Mist v2 TTS models with SignalWire AI Voice applications.
---
+import { VoiceWidget } from "@/components/index";
+
Rime offers uniquely realistic voices with a focus on natural expressiveness.
@@ -33,6 +35,10 @@ Rime offers uniquely realistic voices with a focus on natural expressiveness.
## Voices
+Press play to audition any Rime voice, then **copy config** to grab the value for SWML or your SDK.
+
+
+
Mist v2 is the default Rime model on the SignalWire platform.
To use this model, simply set the voice ID.
diff --git a/package.json b/package.json
index 2186dd0702..98df3ec79c 100644
--- a/package.json
+++ b/package.json
@@ -23,9 +23,11 @@
"postman:publish": "node scripts/postman/build-collection.mjs publish"
},
"devDependencies": {
+ "@types/react": "^18.3.12",
"fern-api": "5.44.4",
"js-yaml": "^4.1.0",
- "openapi-to-postmanv2": "6.0.1"
+ "openapi-to-postmanv2": "6.0.1",
+ "react": "^18.3.1"
},
"engines": {
"node": ">=22"
diff --git a/yarn.lock b/yarn.lock
index d32742fa28..8dcf1b4c3d 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -310,6 +310,19 @@
resolved "https://registry.yarnpkg.com/@sindresorhus/merge-streams/-/merge-streams-4.0.0.tgz#abb11d99aeb6d27f1b563c38147a72d50058e339"
integrity sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==
+"@types/prop-types@*":
+ version "15.7.15"
+ resolved "https://registry.yarnpkg.com/@types/prop-types/-/prop-types-15.7.15.tgz#e6e5a86d602beaca71ce5163fadf5f95d70931c7"
+ integrity sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==
+
+"@types/react@^18.3.12":
+ version "18.3.31"
+ resolved "https://registry.yarnpkg.com/@types/react/-/react-18.3.31.tgz#b5e95e28ffcceab8d982f33f2eb076e17653c2a4"
+ integrity sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==
+ dependencies:
+ "@types/prop-types" "*"
+ csstype "^3.2.2"
+
"@typespec/asset-emitter@^0.79.1":
version "0.79.1"
resolved "https://registry.yarnpkg.com/@typespec/asset-emitter/-/asset-emitter-0.79.1.tgz#bacb659f18ffa0ec8fb3b5a47f8e304bca8a226a"
@@ -544,6 +557,11 @@ compute-lcm@^1.1.2:
validate.io-function "^1.0.2"
validate.io-integer-array "^1.0.0"
+csstype@^3.2.2:
+ version "3.2.3"
+ resolved "https://registry.yarnpkg.com/csstype/-/csstype-3.2.3.tgz#ec48c0f3e993e50648c86da559e2610995cf989a"
+ integrity sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==
+
emoji-regex@^10.3.0:
version "10.6.0"
resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-10.6.0.tgz#bf3d6e8f7f8fd22a65d9703475bc0147357a6b0d"
@@ -749,7 +767,7 @@ is-unicode-supported@^2.1.0:
resolved "https://registry.yarnpkg.com/is-unicode-supported/-/is-unicode-supported-2.1.0.tgz#09f0ab0de6d3744d48d265ebb98f65d11f2a9b3a"
integrity sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==
-js-tokens@^4.0.0:
+"js-tokens@^3.0.0 || ^4.0.0", js-tokens@^4.0.0:
version "4.0.0"
resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499"
integrity sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==
@@ -826,6 +844,13 @@ lodash@^4.17.15, lodash@^4.17.20, lodash@^4.17.4:
resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.18.1.tgz#ff2b66c1f6326d59513de2407bf881439812771c"
integrity sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==
+loose-envify@^1.1.0:
+ version "1.4.0"
+ resolved "https://registry.yarnpkg.com/loose-envify/-/loose-envify-1.4.0.tgz#71ee51fa7be4caec1a63839f7e682d8132d30caf"
+ integrity sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==
+ dependencies:
+ js-tokens "^3.0.0 || ^4.0.0"
+
merge2@^1.3.0:
version "1.4.1"
resolved "https://registry.yarnpkg.com/merge2/-/merge2-1.4.1.tgz#4368892f885e907455a6fd7dc55c0c9d404990ae"
@@ -1050,6 +1075,13 @@ queue-microtask@^1.2.2:
resolved "https://registry.yarnpkg.com/queue-microtask/-/queue-microtask-1.2.3.tgz#4929228bbc724dfac43e0efb058caf7b6cfb6243"
integrity sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==
+react@^18.3.1:
+ version "18.3.1"
+ resolved "https://registry.yarnpkg.com/react/-/react-18.3.1.tgz#49ab892009c53933625bd16b2533fc754cab2891"
+ integrity sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==
+ dependencies:
+ loose-envify "^1.1.0"
+
reftools@^1.1.9:
version "1.1.9"
resolved "https://registry.yarnpkg.com/reftools/-/reftools-1.1.9.tgz#e16e19f662ccd4648605312c06d34e5da3a2b77e"