diff --git a/.gitignore b/.gitignore
index 8bbf8e72f5..61594e6e47 100644
--- a/.gitignore
+++ b/.gitignore
@@ -15,4 +15,5 @@ dist
.docs-review
# Internal process artifacts (superpowers / brainstorming specs)
-docs/superpowers/
\ No newline at end of file
+docs/superpowers/
+.superpowers/
\ No newline at end of file
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 (
+
+ {Array.from({ length: lines }).map((_, i) => (
+
+ ))}
+
+ );
+}
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/engine.tsx b/fern/components/voice-widget/engine.tsx
new file mode 100644
index 0000000000..bc78edf2a6
--- /dev/null
+++ b/fern/components/voice-widget/engine.tsx
@@ -0,0 +1,220 @@
+import { useEffect, useState } from "react";
+import type { Catalog, Manifest, VoiceRow } from "./types";
+
+// Shared engine for the Voice Widget (components/voice-widget) — the data layer + display-time
+// helpers + small shared UI (the filter ) that the widget reuses. Kept in its own module so
+// the widget file holds only rendering. Pure data consumer: fetches catalog.json + manifest.json
+// produced by the TTS pipeline and exposes the joined rows; the widget 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".
+export const ASSET_BASE = "https://mcdn.signalwire.com/voice_widget/dist"; // catalog.json + manifest.json
+export const AUDIO_BASE = "https://mcdn.signalwire.com"; // /audio//.mp3
+
+export const ALL = "__all__";
+export const DEFAULT_PAGE_SIZE = 48;
+export const MOBILE_PAGE_SIZE = 12;
+const MOBILE_QUERY = "(max-width: 640px)";
+export const PAGE_SIZE_OPTIONS = [4, 8, 12, 24, 48, 96];
+
+// ── Display-time data cleanup ────────────────────────────────────────────────────────────────
+// These three helpers normalize the raw catalog values for display only — the underlying data,
+// the copy payload, and the tooltips keep the originals. They are an INTERIM UI fix: the canonical
+// home for this cleanup is the generation pipeline (temp/voice_widget/providers/{elevenlabs,
+// cartesia}.py via make_voice()), so re-running the pipeline and re-uploading the CDN bundle is a
+// clean lift-and-shift that retires these. The model string is shown verbatim (no remapping).
+
+// Clean display name: ElevenLabs/Cartesia bake a marketing sentence into the name
+// ("Austin Knox V3 - Good ol' Texas boy…"); split on the first SPACE-BOUNDED dash (" -"/" –"/" —")
+// and keep the leading segment. Verified against the full catalog: fires only on ElevenLabs (70) +
+// Cartesia (535), 0 false positives — Google/Polly/Azure names use spaceless hyphens
+// ("ar-XA-Chirp3-HD-Achernar", "Joanna-Neural") and are never touched. The full original stays in
+// title=/the icon tooltip. Pipeline home: providers/{elevenlabs,cartesia}.py make_voice().
+export function cleanName(displayName: string): string {
+ const m = displayName.match(/\s[-–—]\s/);
+ if (!m || m.index === undefined) return displayName;
+ const lead = displayName.slice(0, m.index).trim();
+ // Guard against degenerate leads (too short/long to be a real name) — fall back to the original.
+ return lead.length >= 2 && lead.length <= 28 ? lead : displayName;
+}
+
+// Friendly language: turn a BCP-47 code ("af-ZA", "el") into an English name via Intl.DisplayNames
+// (resolves ~99% of real codes); fall back to the raw code on any miss/throw.
+export function friendlyLanguage(code: string): string {
+ if (!code) return code;
+ try {
+ return new Intl.DisplayNames(["en"], { type: "language" }).of(code) || code;
+ } catch {
+ return code;
+ }
+}
+
+// Normalize gender: the catalog mixes "feminine"/"masculine" with "male"/"female"; map to the
+// display forms and HIDE "unknown" (599 voices) rather than render "Unknown". Returns "" to hide.
+export function normalizeGender(gender: string): string {
+ switch (gender) {
+ case "feminine": case "female": return "Female";
+ case "masculine": case "male": return "Male";
+ case "neutral": return "Neutral";
+ case "non-binary": return "Non-binary";
+ case "unknown": case "": return "";
+ default: return gender.charAt(0).toUpperCase() + gender.slice(1);
+ }
+}
+
+export 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).
+export 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).
+export const bundleCache = new Map>();
+
+export 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 row survive filter/page changes (the memoized row
+ // skips re-rendering), and play state highlights exactly one row 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 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).
+export 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 list with no section headers or group toggle. Default: "provider". */
+ groupBy?: "provider" | "language" | "none";
+ /** Voices rendered per page (page-size limit). Default: 48. Keeps the DOM small and the catalog fast. */
+ pageSize?: 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 + list). An object toggles individual controls — `search`, `provider`, `language`,
+ * `gender`, `group`, `pageSize` — each defaulting on unless set `false`. The search control
+ * defaults to `false` (hidden) unless explicitly set to `true`. The provider control is
+ * always hidden when the `provider` lock prop is set.
+ */
+ filters?: boolean | Partial>;
+}
+
+export function Select({ label, value, onChange, opts }:
+ { label: string; value: string; onChange: (v: string) => void; opts: string[] }) {
+ return (
+
+ {label}
+ onChange(e.target.value)}>
+ all
+ {opts.map((o) => {o} )}
+
+
+ );
+}
+
+export function uniq(xs?: string[]): string[] {
+ return [...new Set((xs ?? []).filter(Boolean))].sort();
+}
+
+// Windowed page list for the numbered pager: first page, last page, and current ±1, with `null`
+// marking each collapsed gap (rendered as an ellipsis). A gap of exactly one page emits that page
+// number instead — an ellipsis standing in for a single page reads worse than just showing it.
+// Pages are 0-based here, 1-based in the UI.
+export function pageNumbers(current: number, count: number): (number | null)[] {
+ const wanted = [...new Set([0, count - 1, current - 1, current, current + 1])]
+ .filter((p) => p >= 0 && p < count)
+ .sort((a, b) => a - b);
+ const out: (number | null)[] = [];
+ for (let i = 0; i < wanted.length; i++) {
+ if (i && wanted[i] - wanted[i - 1] > 1) {
+ if (wanted[i] - wanted[i - 1] === 2) out.push(wanted[i] - 1);
+ else out.push(null);
+ }
+ out.push(wanted[i]);
+ }
+ return out;
+}
+
+// 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.
+export 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/index.tsx b/fern/components/voice-widget/index.tsx
new file mode 100644
index 0000000000..7482f80b65
--- /dev/null
+++ b/fern/components/voice-widget/index.tsx
@@ -0,0 +1,453 @@
+import { memo, useCallback, useDeferredValue, useEffect, useMemo, useRef, useState } from "react";
+import type { ReactNode } from "react";
+import type { VoiceRow } from "./types";
+import { Skeleton } from "../skeleton/index";
+import {
+ // Data layer + display-time helpers + shared toolbar UI live in ./engine; this file holds only
+ // the row rendering and the row skeleton.
+ ALL,
+ ASSET_BASE,
+ AUDIO_BASE,
+ DEFAULT_PAGE_SIZE,
+ MOBILE_PAGE_SIZE,
+ PAGE_SIZE_OPTIONS,
+ Select,
+ cleanName,
+ friendlyLanguage,
+ loadBundle,
+ modelKeyOf,
+ normalizeGender,
+ pageNumbers,
+ uniq,
+ useIsMobile,
+} from "./engine";
+import type { FilterKey, Row, VoiceWidgetProps } from "./engine";
+
+// The TTS voice browser embedded in the docs: a bordered, rounded list of hairline-divided two-line
+// rows (cleaned name over muted plain-middot meta), play on the left and a labeled "Copy config" on
+// the right — modeled on the ElevenLabs voice picker. Rendering only; the data/cleanup/toolbar logic
+// is reused from ./engine. Styles live in this folder's styles.css (loaded via docs.yml `css:`).
+
+export function VoiceWidget({
+ assetBaseUrl = ASSET_BASE,
+ catalogUrl = `${assetBaseUrl}/catalog.json`,
+ manifestUrl = `${assetBaseUrl}/manifest.json`,
+ audioBaseUrl = AUDIO_BASE,
+ groupBy: initialGroup = "provider",
+ pageSize = DEFAULT_PAGE_SIZE,
+ 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]
+ );
+ 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);
+
+ // Rows are full-width single-column at every breakpoint, but the mobile page-size cap still
+ // applies — a 48-row list 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. The `search` control
+ // defaults to off (must be opted in) — all others default on.
+ const showFilter = (key: FilterKey) =>
+ filters === false ? false
+ : filters && typeof filters === "object"
+ ? key === "search" ? filters[key] === true : filters[key] !== false
+ : key === "search" ? false : true;
+ const showProvider = !lock && showFilter("provider");
+ const showGroup = group !== "none" && showFilter("group") && !lock;
+
+ 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. Also drops no-preview voices (missing/errored clip) so every visible row is
+ // auditionable.
+ const baseRows = useMemo(() => {
+ if (!allRows) return null;
+ return allRows.filter((r) =>
+ r.clip?.status === "ok" &&
+ (!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.
+ 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.
+ 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. group === "none" → a single
+ // unlabeled section (flat list).
+ 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 rows).
+ 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 downloads first, and during that window a second click must read this
+ // row as already playing so it pauses instead of restarting the download. 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 ;
+
+ const pager = pageCount > 1 ? (
+
+ setPage(Math.max(0, safePage - 1))} disabled={safePage <= 0}>
+ ‹ Prev
+
+ {pageNumbers(safePage, pageCount).map((p, i) =>
+ p === null ? (
+ …
+ ) : (
+ setPage(p)}>
+ {p + 1}
+
+ ))}
+ setPage(Math.min(pageCount - 1, safePage + 1))} disabled={safePage >= pageCount - 1}>
+ Next ›
+
+ {showFilter("pageSize") && (
+
+ setPageSizeChoice(Number(e.target.value))}>
+ {pageSizeOpts.map((n) => {n} / page )}
+
+
+ )}
+
+ ) : null;
+
+ return (
+
+
setPlayingKey(null)} preload="none" />
+
+
+ {(showFilter("search") || showProvider || showFilter("language") || showFilter("gender") || showGroup) && (
+
+ {showProvider &&
}
+ {showFilter("language") &&
}
+ {showFilter("gender") && (
+
+ )}
+ {showFilter("search") && (
+
setQ(e.target.value)} />
+ )}
+ {showGroup && (
+
+ setGroup("provider")}>By provider
+ setGroup("language")}>By language
+
+ )}
+
+ )}
+
+ {pageSections.length === 0 ? (
+ No voices match your filters.
+ ) : (
+ pageSections.map((sec, i) => (
+
+ {sec.name && {sec.name} {groupTotals.get(sec.name)} }
+ {/* The bordered, rounded list container. A real so screen readers announce it as a
+ list of N voices; each row is a /. Fades/rises in once
+ loaded (reduced-motion-gated in CSS). */}
+
+ {sec.items.map((r) => (
+
+ ))}
+
+
+ ))
+ )}
+
+ {pager}
+
+ );
+}
+
+// The loaded list, fading/rising into the slot the skeleton list held (no hard swap; reduced-motion-
+// gated in CSS). The `vw-fadein` animation makes the list a stacking context permanently
+// (animation-fill-mode), which would trap a first row's sample-text popover below the sticky toolbar
+// — so drop the class once the animation ends to restore z-index:auto.
+function FadeList({ children }: { children: ReactNode }) {
+ const [animating, setAnimating] = useState(true);
+ return (
+ setAnimating(false)}>
+ {children}
+
+ );
+}
+
+// Inline SVG icons with the shared `vw-` styling hooks (Fern's / aren't importable in
+// custom TSX: they are global MDX-only components with no @fern-* package to import from, and the
+// components bundler only resolves React + this folder). Self-contained SVGs are the only path that's
+// guaranteed to render; decorative ones are marked aria-hidden where rendered.
+const IconInfo = () => (
+
+);
+const IconCopy = () => (
+
+);
+// Play/stop as inline SVG (not the "▶"/"■" chars, which are emoji-capable and render as an OS color
+// emoji on some platforms). Filled with currentColor so they follow the button's rest/hover/playing
+// color; the triangle is nudged right of center to read optically centered.
+const IconPlay = () => (
+
+);
+const IconStop = () => (
+
+);
+
+// Memoized so playing/pausing a voice (which re-renders the widget) only re-renders the two rows
+// whose `playing` flag actually changed — not the whole page of rows.
+const VoiceRowItem = memo(function VoiceRowItem({ r, playing, onPlay, onCopy }: {
+ r: Row; playing: boolean; onPlay: (r: Row) => void; onCopy: (r: VoiceRow) => Promise | void;
+}) {
+ const [copied, setCopied] = useState(false);
+ const copyTimer = useRef | undefined>(undefined);
+ 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);
+ });
+ };
+
+ // Display-time cleanup (originals stay in the catalog, the copy payload, and the title/tooltip).
+ const name = cleanName(r.display_name);
+ const language = friendlyLanguage(r.primary_language);
+ const gender = normalizeGender(r.gender);
+ const model = r.model || null;
+ const tipId = `vwr-tip-${r._uid}`;
+
+ return (
+ // Each row is a list item with an accessible name so SR users navigate the list as named voices.
+
+
+ onPlay(r)}
+ aria-label={playing ? `Stop ${name}` : `Play ${name}`}>
+ {playing ? (
+ <>
+ {/* Animated bars normally; a static stop square under prefers-reduced-motion (CSS swap). */}
+
+
+ >
+ ) : }
+
+
+ {/* Name + meta block — min-width:0 so the name/meta can ellipsize instead of widening the row. */}
+
+
+ {/* Cleaned name; the full original stays in title= (and the sample-text popover by Copy).
+ lang tags the native script for screen-reader pronunciation + language-appropriate glyphs. */}
+ {name}
+
+ {/* Meta — muted plain-middot: language · gender · provider · model. No type icons (density).
+ "unknown" gender is hidden. Provider is ALWAYS shown. Model is the neutral inline-code.
+ The whole line truncates with ellipsis; never wraps (keeps row height uniform). */}
+
+ {language}
+ {gender && <>· {gender} >}
+ ·
+ {r.provider}
+ {model && <>
+ ·
+ {model}
+ >}
+
+
+
+ {/* Sample-text popover — its trigger sits on the right, just left of Copy, where there's
+ room (rather than crowding the name). Focusable + aria-describedby so the sample is
+ announced (not just shown on hover); reveal is hover/focus-within. The popover opens
+ upward and is right-anchored (see CSS) so it stays within the row width. */}
+ {r.clip?.sample_text && (
+
+
+ “{r.clip.sample_text}”
+
+ )}
+ {/* Copy — always labeled (rows have the horizontal room; no icon-only variant). Copied →
+ "✓ Copied" in --status-success. */}
+
+ {copied ? "✓ Copied" : "Copy config"}
+
+
+
+ );
+});
+
+// Loading state: skeleton ROWS in the SAME list container at MATCHING row height (zero layout shift).
+// A skeleton row mirrors the real one — play circle + a name bar + a shorter meta bar + a copy bar.
+// The real row's two text lines (name + meta) carry a 2px gap, so the skeleton stacks its name/meta
+// bars with the same rhythm; the row's vertical padding (CSS) is shared by both so heights match.
+// `pills` is the number of toolbar pill placeholders — pass the count of controls that actually
+// render so a filters-off embed gets no phantom toolbar.
+function VoiceRowsSkeleton({ pills = 3 }: { pills?: number }) {
+ return (
+
+
+ {pills > 0 && (
+
+ {Array.from({ length: pills }).map((_, i) => (
+
+ ))}
+
+ )}
+
+ {Array.from({ length: 8 }).map((_, i) => (
+
+
+
+ ))}
+
+
+ );
+}
diff --git a/fern/components/voice-widget/styles.css b/fern/components/voice-widget/styles.css
new file mode 100644
index 0000000000..5e906f9674
--- /dev/null
+++ b/fern/components/voice-widget/styles.css
@@ -0,0 +1,262 @@
+/* Non-Latin voice-name fallback. The catalog carries ~190 names across ~24
+ scripts (Arabic, CJK, Devanagari, Ethiopic, Hangul, …) and the docs body font (Lexend, Latin-only
+ subset) can't render them — Firefox then walks the SYSTEM font list and tofu's on any script the OS
+ lacks. Fix: serve Noto via the Google Fonts CSS API, which returns one @font-face per script EACH
+ WITH ITS OWN unicode-range, so the browser downloads only the slices whose glyphs actually appear
+ (CJK is the only heavy one and is itself range-sliced). Needs no build-time knowledge of the glyph
+ set — right for a runtime CDN catalog. Wired into --vw-i18n-font below, used per-character after the
+ body font (Latin stays Lexend). If a CSP ever blocks fonts.googleapis.com/gstatic.com, self-host
+ range-subsetted Noto instead. */
+@import url('https://fonts.googleapis.com/css2?family=Noto+Sans:wght@400;700&family=Noto+Sans+Arabic:wght@400;700&family=Noto+Sans+Hebrew:wght@400;700&family=Noto+Sans+Devanagari:wght@400;700&family=Noto+Sans+Bengali:wght@400;700&family=Noto+Sans+Tamil:wght@400;700&family=Noto+Sans+Telugu:wght@400;700&family=Noto+Sans+Kannada:wght@400;700&family=Noto+Sans+Malayalam:wght@400;700&family=Noto+Sans+Gujarati:wght@400;700&family=Noto+Sans+Gurmukhi:wght@400;700&family=Noto+Sans+Oriya:wght@400;700&family=Noto+Sans+Sinhala:wght@400;700&family=Noto+Sans+Thai:wght@400;700&family=Noto+Sans+Lao:wght@400;700&family=Noto+Sans+Khmer:wght@400;700&family=Noto+Sans+Myanmar:wght@400;700&family=Noto+Sans+Ethiopic:wght@400;700&family=Noto+Sans+Armenian:wght@400;700&family=Noto+Sans+Georgian:wght@400;700&family=Noto+Sans+Canadian+Aboriginal:wght@400;700&family=Noto+Sans+SC:wght@400;700&family=Noto+Sans+JP:wght@400;700&family=Noto+Sans+KR:wght@400;700&display=swap');
+
+/* Voice widget — compact rows. Ported from the design mockups in
+ docs/superpowers/specs/2026-06-14-voice-widget-rows-assets/ (vw-rows-theme.html / vw-rows.html /
+ vw-rows-skeleton.html), with their hardcoded hex mapped to the docs' theme-aware brand tokens
+ (fern/brand-tokens.css) so the widget follows light/dark automatically:
+ mockup --page → --bg-page --surface/--card → --bg-surface-raised --ink → --fg-default
+ --meta/--ink2/--codefg → --fg-secondary --muted → --fg-muted --line → --border-default
+ --line2 → --grayscale-a4 (lighter hairline) --hover → --table-row-hover
+ --accent → --accent --accon → --accent-contrast --codebg → --code-bg --good → --status-success
+
+ Self-contained and scoped under .vw.vw-rows (the widget's root className). The model code is
+ deliberately NEUTRAL (--fg-secondary), NOT --code-fg: the docs inline- --code-fg equals
+ --accent in both themes (blue/turquoise), so a default would render the model the same
+ color as Copy and read as clickable. */
+.vw.vw-rows {
+ --vw-bg: var(--bg-page);
+ --vw-card: var(--bg-surface-raised);
+ --vw-line: var(--border-default);
+ /* Lighter than the container border — used only for the row dividers. Grayscale alpha so it stays
+ subtle on both the light (#E8EAF0) and dark (#222436) surfaces. */
+ --vw-line2: var(--grayscale-a4);
+ --vw-fg: var(--fg-default);
+ --vw-fg2: var(--fg-secondary);
+ --vw-muted: var(--fg-muted);
+ --vw-accent: var(--accent);
+ /* i18n name stack: the docs body font FIRST (Latin names stay Lexend), then the Noto script
+ families (loaded by the @import above) for per-character fallback on non-Latin glyphs. Applied
+ only to the text that comes from the catalog (.vw-name, .vw-tooltip) — meta/provider/model are
+ always Latin. CJK order is SC→JP→KR: the catalog's Han names are mostly Chinese (zh/wuu/yue), so
+ SC first gives the majority correct glyph shapes; Japanese kanji render with SC Han forms
+ (legible, a hair off — Han unification), Korean Hangul resolves to KR regardless. */
+ --vw-i18n-font: var(--typography-body-font-family), "Noto Sans", "Noto Sans Arabic",
+ "Noto Sans Hebrew", "Noto Sans Devanagari", "Noto Sans Bengali", "Noto Sans Tamil",
+ "Noto Sans Telugu", "Noto Sans Kannada", "Noto Sans Malayalam", "Noto Sans Gujarati",
+ "Noto Sans Gurmukhi", "Noto Sans Oriya", "Noto Sans Sinhala", "Noto Sans Thai", "Noto Sans Lao",
+ "Noto Sans Khmer", "Noto Sans Myanmar", "Noto Sans Ethiopic", "Noto Sans Armenian",
+ "Noto Sans Georgian", "Noto Sans Canadian Aboriginal", "Noto Sans SC", "Noto Sans JP",
+ "Noto Sans KR", sans-serif;
+ /* Size/line-height only — DON'T override font-family here: inherit the docs body font (Lexend) for
+ the chrome; the non-Latin fallback is scoped to .vw-name/.vw-tooltip via --vw-i18n-font. The
+ earlier `system-ui` override tofu'd non-Latin names on Firefox (system fallback misses scripts). */
+ color: var(--vw-fg); font-size: 14px; line-height: 1.45;
+}
+.vw.vw-rows.vw-error { padding: 2rem; color: var(--error-text); }
+
+/* ── Header (title + live count) ─────────────────────────────────────────────────────────────── */
+.vw-rows .vw-head { display: flex; align-items: baseline; gap: 1rem; margin-bottom: .6rem; }
+.vw-rows .vw-title { font-size: 1.1rem; font-weight: 700; }
+/* --fg-secondary (not --fg-muted): small text, --fg-muted (#737371) is only ~3.95:1 on the light
+ surface — below the AA 4.5:1 floor. */
+.vw-rows .vw-count { color: var(--vw-fg2); font-weight: 400; font-size: .82rem; margin-left: .4rem; }
+
+/* ── Toolbar: pill controls; sticks below the fixed docs header while the list scrolls. ────────── */
+.vw-rows .vw-filters { display: flex; flex-wrap: wrap; gap: .5rem; align-items: center; margin-bottom: 1rem;
+ position: sticky; top: var(--header-height, 0px); background: var(--vw-bg); padding: .5rem 8px; z-index: 2; }
+/* flex:1 1 auto (NOT 1 1 0): the selects stretch to fill the bar edge-to-edge, but keep their content
+ width as the flex-basis so the free space is shared on top of it — final widths track each pill's
+ label/value length (Provider wider than Gender) rather than being forced equal. */
+.vw-rows .vw-select { display: flex; align-items: center; gap: .35rem; flex: 1 1 auto; min-width: 0;
+ font-size: .8rem; color: var(--vw-muted);
+ background: var(--vw-card); border: 1px solid var(--vw-line); border-radius: 9px; padding: .4rem .7rem; }
+/* flex:1 + min-width:0 so the native control fills the stretched pill (and can shrink to ellipsis). */
+.vw-rows .vw-select select { border: none; background: transparent; color: var(--vw-fg); font-weight: 600;
+ font-size: .8rem; padding: 0; cursor: pointer; flex: 1; min-width: 0; }
+/* Chromium derives the option popup's colors from the select's computed background — transparent
+ would yield a white popup with near-white text in dark mode, so pin both explicitly. */
+.vw-rows .vw-select option { background: var(--vw-card); color: var(--vw-fg); }
+.vw-rows .vw-search { padding: .4rem .7rem; min-width: 200px; background: var(--vw-card);
+ border: 1px solid var(--vw-line); border-radius: 9px; color: inherit; font-size: .8rem; }
+
+/* Group toggle as a segmented control: gray track, raised active segment. */
+.vw-rows .vw-group { display: flex; margin-left: auto; background: var(--grayscale-a3); border-radius: 9px; padding: 2px; }
+.vw-rows .vw-group button { padding: .35rem .7rem; background: transparent; color: var(--vw-muted); border: none;
+ border-radius: 7px; cursor: pointer; font-size: .78rem; }
+.vw-rows .vw-group button.on { background: var(--vw-card); color: var(--vw-fg); font-weight: 600;
+ box-shadow: 0 1px 3px rgba(0,0,0,.12); }
+
+/* Section header between row-groups when grouping by provider/language. */
+.vw-rows .vw-rsection { margin-bottom: 1.25rem; }
+.vw-rows .vw-section-title { font-size: .85rem; font-weight: 650; margin: .5rem 0 .55rem; text-transform: capitalize; }
+.vw-rows .vw-section-title span { color: var(--vw-muted); font-weight: 400; margin-left: .3rem; }
+
+/* ── The list: bordered, rounded container that lifts off the page (like the card). ───────────── */
+/* overflow:VISIBLE (not hidden): a row's sample-text popover must be able to escape the list — the
+ cards' grid likewise never clips. The rounded corners are instead carried by rounding the first/
+ last rows below, so on-hover row backgrounds still follow the container's rounded shape. */
+.vw-rows .vw-list { list-style: none; margin: 0; padding: 0;
+ border: 1px solid var(--vw-line); border-radius: 14px; overflow: visible; background: var(--vw-card);
+ box-shadow: var(--card-shadow), var(--card-inset); }
+
+/* Row: hairline-divided, full-width, uniform height; subtle background on hover. The inner
+ carries the flex layout + the per-row accessible name. overflow:visible on the row so the sample-
+ text popover can escape upward.
+ margin/padding RESET: the rows are s rendered inside MDX, so Fern's prose styles apply a
+ vertical `margin: 7px 0` and a `padding-inline-start` to every . The margins open a ~7px gap
+ between rows (filled by the list's card background) that the per-row :hover background — painted on
+ the border-box — does NOT cover, so the highlight cut off short above/below each row; the
+ inline padding also indented the rows. Zero both so the hover fill spans the full row edge-to-edge
+ and rows sit flush like the source mockup. */
+.vw-rows .vw-row { margin: 0; padding: 0; border-bottom: 1px solid var(--vw-line2); transition: background .12s ease; }
+.vw-rows .vw-row:last-child { border-bottom: none; }
+/* Round the end rows to ~the list's inner radius (14px border-radius − 1px border) so a hovered
+ first/last row's background doesn't square off the container's rounded corners now that the list
+ no longer clips. */
+.vw-rows .vw-row:first-child { border-top-left-radius: 13px; border-top-right-radius: 13px; }
+.vw-rows .vw-row:last-child { border-bottom-left-radius: 13px; border-bottom-right-radius: 13px; }
+.vw-rows .vw-row:hover { background: var(--table-row-hover); }
+.vw-rows .vw-row:focus-within { background: var(--table-row-hover); }
+.vw-rows .vw-row-inner { display: flex; align-items: center; gap: 13px; padding: 10px 15px;
+ min-height: 58px; position: relative; }
+/* The row deliberately does NOT take a z-index on hover. Lifting it made the whole row a stacking
+ context above the sticky toolbar (z-index:2), so a row scrolled partly under the toolbar painted
+ OVER it on hover. The row stays at z-index:auto (below the toolbar); only the sample-text popover
+ (z-index:30) needs to escape, and it beats the toolbar's z-index on its own — so the popover
+ paints above the toolbar while the row body never does. */
+
+/* Ghost play button: 38px outline circle at rest; fills with the accent + equalizer while playing. */
+.vw-rows .vw-play { width: 38px; height: 38px; flex: 0 0 auto; border-radius: 50%; cursor: pointer;
+ background: transparent; color: var(--vw-fg); border: 1.5px solid var(--vw-line); font-size: 11px;
+ display: inline-flex; align-items: center; justify-content: center;
+ transition: background .15s ease, border-color .15s ease, color .15s ease; }
+.vw-rows .vw-play:hover:not(.vw-playing) { border-color: var(--vw-accent); color: var(--vw-accent); }
+.vw-rows .vw-play.vw-playing { background: var(--vw-accent); border-color: var(--vw-accent); color: var(--accent-contrast); }
+/* Play (rest) + stop (reduced-motion) SVG glyphs, sized to the old "▶"/"■" footprint. */
+.vw-rows .vw-play svg { width: 13px; height: 13px; display: block; }
+
+/* Equalizer bars while playing; .vw-stop is the reduced-motion fallback (swapped in below). */
+.vw-rows .vw-eq { display: inline-flex; gap: 2.5px; align-items: flex-end; height: 12px; }
+.vw-rows .vw-eq i { width: 2.5px; background: currentColor; border-radius: 1px; transform-origin: 50% 100%;
+ animation: vw-eqb 1s ease-in-out infinite; }
+.vw-rows .vw-eq i:nth-child(1) { height: 60%; }
+.vw-rows .vw-eq i:nth-child(2) { height: 100%; animation-delay: .2s; }
+.vw-rows .vw-eq i:nth-child(3) { height: 45%; animation-delay: .4s; }
+@keyframes vw-eqb { 0%, 100% { transform: scaleY(.5); } 50% { transform: scaleY(1); } }
+.vw-rows .vw-stop { display: none; }
+
+/* Name + meta block — min-width:0 so the name/meta ellipsize instead of widening the row. The two
+ lines carry FIXED line-heights (18px + 16px + the 2px gap = 36px) so the block is shorter than the
+ 38px play circle: the play circle is then the tallest cell in every row, which (with the row's
+ min-height:58px and 10px top/bottom padding → 58px) keeps every loaded row EXACTLY the same height
+ as a skeleton row (whose 38px play placeholder is likewise the tallest) → zero layout shift. */
+.vw-rows .vw-row-mid { flex: 1; min-width: 0; }
+.vw-rows .vw-row-nm { display: flex; align-items: center; gap: 6px; min-width: 0; }
+/* Name: cleaned display name, single line, ellipsis if absurdly long (cleaned names fit). The full
+ original lives in title=/the icon tooltip. The display:block + word-break/line-clamp resets
+ override the cards' bare `.vw-name` (which sets -webkit-box + -webkit-line-clamp:2 for the cards'
+ 2-line wrap; it also matches here because the rows root carries the `vw` class) so the row name
+ stays strictly single-line with a tail ellipsis. */
+.vw-rows .vw-name { display: block; flex: 0 1 auto; font-family: var(--vw-i18n-font); font-weight: 650;
+ font-size: 14.5px; line-height: 18px;
+ letter-spacing: -.01em; color: var(--vw-fg); white-space: nowrap; word-break: normal;
+ -webkit-line-clamp: none; overflow: hidden; text-overflow: ellipsis; min-width: 0; }
+
+/* Meta — muted plain-middot: language · gender · provider · model. Single line; truncates with
+ ellipsis (never wraps → uniform row height). */
+.vw-rows .vw-row-meta { display: flex; align-items: center; gap: 6px; margin-top: 2px;
+ font-size: 12px; line-height: 16px; color: var(--vw-fg2);
+ white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
+.vw-rows .vw-row-meta > span { flex: 0 0 auto; }
+.vw-rows .vw-dot { opacity: .5; }
+.vw-rows .vw-gender { text-transform: capitalize; }
+/* Provider in the meta is plain text (NOT the uppercase chip the cards use in their footer) — it
+ reads as one of the middot-separated meta facts. The text-transform/letter-spacing/font-* resets
+ override the cards' bare `.vw-prov` rule (loaded globally; it also matches because the rows root
+ carries the `vw` class). Shrink-safe so a long provider truncates. */
+.vw-rows .vw-prov { min-width: 0; overflow: hidden; text-overflow: ellipsis; flex: 0 1 auto;
+ text-transform: none; letter-spacing: normal; font-size: inherit; font-weight: inherit;
+ color: inherit; white-space: nowrap; }
+/* Model: the real (normalized) model as NEUTRAL inline-code — mono + --code-bg + --fg-secondary fg
+ (NOT --code-fg, which equals --accent and would read as clickable). Single-line; ellipsizes at the
+ tail if unusually long; the raw model stays in title=/copy. */
+.vw-rows .vw-model { font: 11px/1.5 ui-monospace, SFMono-Regular, Menlo, monospace; color: var(--vw-fg2);
+ background: var(--code-bg); border-radius: 5px; padding: 1px 6px;
+ white-space: nowrap; overflow: hidden; text-overflow: ellipsis; min-width: 0; flex: 0 1 auto; }
+
+/* Sample-text popover: a fixed-width (220px), icon-anchored disclosure on the right of the row (left
+ of Copy). Opens upward and is RIGHT-anchored so it extends leftward into the row and never spills
+ off the right edge. Revealed on hover/focus-within; the trigger carries aria-describedby → .vw-tooltip. */
+.vw-rows .vw-tooltip-wrap { flex: 0 0 auto; position: relative; display: inline-flex; align-items: center;
+ color: var(--vw-muted); cursor: help; transition: color .15s ease; }
+.vw-rows .vw-tooltip-wrap:hover, .vw-rows .vw-tooltip-wrap:focus-within { color: var(--vw-accent); }
+.vw-rows .vw-tooltip-icon { display: inline-flex; opacity: .75; }
+.vw-rows .vw-tooltip-wrap:hover .vw-tooltip-icon,
+.vw-rows .vw-tooltip-wrap:focus-within .vw-tooltip-icon { opacity: 1; }
+.vw-rows .vw-tooltip-icon svg { width: 14px; height: 14px; }
+.vw-rows .vw-tooltip { display: none; position: absolute; bottom: calc(100% + 9px); right: -8px; width: 220px;
+ background: var(--vw-card); color: var(--vw-fg2);
+ border: 1px solid var(--vw-line); border-radius: 9px; padding: 8px 11px;
+ font-family: var(--vw-i18n-font); font-size: 11.5px; font-style: italic; line-height: 1.45;
+ box-shadow: var(--card-hover-shadow); z-index: 30; pointer-events: none; white-space: normal; }
+.vw-rows .vw-tooltip::after { content: ""; position: absolute; top: 100%; right: 14px;
+ border: 6px solid transparent; border-top-color: var(--vw-card); }
+.vw-rows .vw-tooltip-wrap:hover .vw-tooltip,
+.vw-rows .vw-tooltip-wrap:focus-within .vw-tooltip { display: block; }
+
+/* Copy — always labeled (rows have the room; no icon-only variant). Copied → --status-success. */
+.vw-rows .vw-copy-t { flex: 0 0 auto; font-size: 12.5px; color: var(--vw-accent); background: none; border: none;
+ font-weight: 600; display: inline-flex; align-items: center; gap: 6px; cursor: pointer; padding: 0;
+ white-space: nowrap; }
+.vw-rows .vw-copy-t svg { width: 13px; height: 13px; }
+.vw-rows .vw-copy-t.vw-copied { color: var(--status-success); }
+
+/* Smooth resolve: the loaded list fades/rises into the slot the skeleton list held. Reduced-motion-
+ gated below. */
+.vw-rows .vw-list.vw-fadein { animation: vw-in .45s cubic-bezier(.2,.7,.2,1) both; }
+@keyframes vw-in { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: none; } }
+
+/* Numbered pager: ghost number buttons, inverted current page, bordered Prev/Next. */
+.vw-rows .vw-pager { display: flex; align-items: center; justify-content: center; gap: .3rem;
+ margin: 1.1rem 0 .25rem; flex-wrap: wrap; }
+.vw-rows .vw-page { min-width: 30px; height: 30px; display: inline-flex; align-items: center; justify-content: center;
+ padding: 0 .35rem; border-radius: 8px; font-size: .8rem; color: var(--vw-fg); background: transparent;
+ border: 1px solid transparent; cursor: pointer; }
+.vw-rows .vw-page:hover:not(:disabled):not(.vw-page-on) { border-color: var(--vw-line); }
+.vw-rows .vw-page-on { background: var(--vw-fg); color: var(--vw-bg); font-weight: 600; }
+.vw-rows .vw-page-nav { border-color: var(--vw-line); background: var(--vw-card); padding: 0 .65rem; }
+.vw-rows .vw-page-nav:hover:not(:disabled) { border-color: var(--vw-accent); color: var(--vw-accent); }
+.vw-rows .vw-page-nav:disabled { opacity: .45; cursor: not-allowed; }
+.vw-rows .vw-page-gap { color: var(--vw-muted); padding: 0 .2rem; }
+.vw-rows .vw-perpage { margin-left: .6rem; }
+.vw-rows .vw-perpage select { font-size: .75rem; color: var(--vw-muted); background: transparent;
+ border: none; cursor: pointer; }
+.vw-rows .vw-perpage option { background: var(--vw-card); color: var(--vw-fg); }
+.vw-rows .vw-empty { padding: 2rem 0; color: var(--vw-fg2); font-size: .9rem; }
+
+/* Reduced motion: static stop glyph instead of animated bars, no transitions, no fade-in. (The
+ shimmer primitive disables its own animation in components/skeleton/styles.css.) */
+@media (prefers-reduced-motion: reduce) {
+ .vw-rows .vw-eq { display: none; }
+ .vw-rows .vw-stop { display: inline; }
+ .vw-rows .vw-row, .vw-rows .vw-play, .vw-rows .vw-tooltip-wrap { transition: none; }
+ .vw-rows .vw-list.vw-fadein { animation: none; }
+}
+
+/* Visible focus rings on every interactive control, both themes (2px accent outline + offset). */
+.vw-rows .vw-play:focus-visible, .vw-rows .vw-copy-t:focus-visible,
+.vw-rows .vw-tooltip-wrap:focus-visible, .vw-rows .vw-page:focus-visible,
+.vw-rows .vw-select select:focus-visible, .vw-rows .vw-search:focus-visible,
+.vw-rows .vw-group button:focus-visible, .vw-rows .vw-perpage select:focus-visible {
+ outline: 2px solid var(--vw-accent); outline-offset: 2px; border-radius: 8px;
+}
+.vw-rows .vw-play:focus-visible { border-radius: 50%; }
+
+/* Mobile: rows stay single-column full-width; the name+meta block shrinks (meta truncates). Copy
+ stays labeled (it fits). */
+@media (max-width: 640px) {
+ .vw-rows .vw-filters { gap: .4rem; }
+ .vw-rows .vw-search { min-width: 0; flex: 1; }
+ .vw-rows .vw-group { margin-left: 0; }
+ .vw-rows .vw-perpage { margin-left: 0; }
+ .vw-rows .vw-row-inner { gap: 11px; padding: 10px 13px; }
+}
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 cb359ca0ef..8e40c23506 100644
--- a/fern/products/platform/pages/calling/voice/TTS/azure.mdx
+++ b/fern/products/platform/pages/calling/voice/TTS/azure.mdx
@@ -6,10 +6,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 415e6a2406..9cca6429f7 100644
--- a/fern/products/platform/pages/calling/voice/TTS/cartesia.mdx
+++ b/fern/products/platform/pages/calling/voice/TTS/cartesia.mdx
@@ -7,6 +7,8 @@ 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.
@@ -35,6 +37,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 ffd214572c..6b9326a0e8 100644
--- a/fern/products/platform/pages/calling/voice/TTS/deepgram.mdx
+++ b/fern/products/platform/pages/calling/voice/TTS/deepgram.mdx
@@ -6,6 +6,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.
@@ -26,6 +28,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 4dc43c2b1a..e57adb8960 100644
--- a/fern/products/platform/pages/calling/voice/TTS/elevenlabs.mdx
+++ b/fern/products/platform/pages/calling/voice/TTS/elevenlabs.mdx
@@ -6,6 +6,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).
@@ -21,6 +23,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 2de55a4b17..0b305ac376 100644
--- a/fern/products/platform/pages/calling/voice/TTS/google.mdx
+++ b/fern/products/platform/pages/calling/voice/TTS/google.mdx
@@ -6,6 +6,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
@@ -37,6 +39,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 975a7a893a..a08115801b 100644
--- a/fern/products/platform/pages/calling/voice/TTS/index.mdx
+++ b/fern/products/platform/pages/calling/voice/TTS/index.mdx
@@ -12,6 +12,9 @@ description: Detailed list of all the TTS providers and voices SignalWire suppor
---
+import { VoiceWidget } from "@/components/index";
+
+
[polly]: /docs/platform/voice/tts/amazon-polly
[azure]: /docs/platform/voice/tts/azure
[cartesia]: /docs/platform/voice/tts/cartesia
@@ -31,6 +34,14 @@ 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
+
+Choose a provider to browse and audition its full voice catalog. Press play to audition a voice, 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/inworld.mdx b/fern/products/platform/pages/calling/voice/TTS/inworld.mdx
index 10800bb434..284d79379a 100644
--- a/fern/products/platform/pages/calling/voice/TTS/inworld.mdx
+++ b/fern/products/platform/pages/calling/voice/TTS/inworld.mdx
@@ -7,6 +7,8 @@ max-toc-depth: 3
---
+import { VoiceWidget } from "@/components/index";
+
Inworld is a text-to-speech engine offering high-quality, expressive voices across many languages.
## Models
@@ -26,6 +28,10 @@ defaults to `inworld-tts-1.5-max`. Set a model explicitly to override this.
## Voices
+Press play to audition any Inworld voice, then **copy config** to grab the value for SWML or your SDK.
+
+
+
Inworld provides a large library of expressive voices across many languages.
A voice's name is its voice ID — for example, `Lauren` becomes `inworld.Lauren` in the
[voice string](#usage).
diff --git a/fern/products/platform/pages/calling/voice/TTS/minimax.mdx b/fern/products/platform/pages/calling/voice/TTS/minimax.mdx
index 88de3efa00..651b3d60f8 100644
--- a/fern/products/platform/pages/calling/voice/TTS/minimax.mdx
+++ b/fern/products/platform/pages/calling/voice/TTS/minimax.mdx
@@ -7,6 +7,8 @@ max-toc-depth: 3
---
+import { VoiceWidget } from "@/components/index";
+
MiniMax is a text-to-speech engine offering expressive voices across many languages, with controls
for emotion, speed, pitch, and volume.
@@ -26,6 +28,10 @@ SignalWire supports the following MiniMax models. Pick a `turbo` model for speed
## Voices
+Press play to audition any MiniMax voice, then **copy config** to grab the value for SWML or your SDK.
+
+
+
MiniMax provides a large library of system voices across many languages.
A voice's **Voice ID** is what you put in the [voice string](#usage): for example,
`English_CalmWoman` becomes `minimax.English_CalmWoman`.
diff --git a/fern/products/platform/pages/calling/voice/TTS/openai.mdx b/fern/products/platform/pages/calling/voice/TTS/openai.mdx
index b3034a5cba..41d7907cf1 100644
--- a/fern/products/platform/pages/calling/voice/TTS/openai.mdx
+++ b/fern/products/platform/pages/calling/voice/TTS/openai.mdx
@@ -6,6 +6,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].
@@ -25,6 +27,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 2a5e237714..ffdda8ec30 100644
--- a/fern/products/platform/pages/calling/voice/TTS/polly.mdx
+++ b/fern/products/platform/pages/calling/voice/TTS/polly.mdx
@@ -6,6 +6,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
@@ -34,6 +36,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 60bff5d9e5..4d9859dc8a 100644
--- a/fern/products/platform/pages/calling/voice/TTS/rime.mdx
+++ b/fern/products/platform/pages/calling/voice/TTS/rime.mdx
@@ -7,6 +7,8 @@ description: Learn how to use Rime's Arcana and Mist v2 TTS models with SignalWi
---
+import { VoiceWidget } from "@/components/index";
+
Rime offers uniquely realistic voices with a focus on natural expressiveness.
## Models
@@ -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..19b1c15b50 100644
--- a/package.json
+++ b/package.json
@@ -23,9 +23,11 @@
"postman:publish": "node scripts/postman/build-collection.mjs publish"
},
"devDependencies": {
+ "@types/react": "^19.0.0",
"fern-api": "5.44.4",
"js-yaml": "^4.1.0",
- "openapi-to-postmanv2": "6.0.1"
+ "openapi-to-postmanv2": "6.0.1",
+ "react": "^19.0.0"
},
"engines": {
"node": ">=22"
diff --git a/yarn.lock b/yarn.lock
index d32742fa28..3b08ed11c9 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -310,6 +310,13 @@
resolved "https://registry.yarnpkg.com/@sindresorhus/merge-streams/-/merge-streams-4.0.0.tgz#abb11d99aeb6d27f1b563c38147a72d50058e339"
integrity sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==
+"@types/react@^19.0.0":
+ version "19.2.17"
+ resolved "https://registry.yarnpkg.com/@types/react/-/react-19.2.17.tgz#dccac365baa0f1734ec270ff4b51c89465e8dc7f"
+ integrity sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==
+ dependencies:
+ 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 +551,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"
@@ -1050,6 +1062,11 @@ queue-microtask@^1.2.2:
resolved "https://registry.yarnpkg.com/queue-microtask/-/queue-microtask-1.2.3.tgz#4929228bbc724dfac43e0efb058caf7b6cfb6243"
integrity sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==
+react@^19.0.0:
+ version "19.2.7"
+ resolved "https://registry.yarnpkg.com/react/-/react-19.2.7.tgz#1f47a1bfc06f8ec885752c6f4af14369a9f8260b"
+ integrity sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==
+
reftools@^1.1.9:
version "1.1.9"
resolved "https://registry.yarnpkg.com/reftools/-/reftools-1.1.9.tgz#e16e19f662ccd4648605312c06d34e5da3a2b77e"