diff --git a/fern/components/index.tsx b/fern/components/index.tsx index 289efdaf69..4ddb224268 100644 --- a/fern/components/index.tsx +++ b/fern/components/index.tsx @@ -5,4 +5,5 @@ // 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 "./voice-widget-rows/index"; export * from "./skeleton/index"; diff --git a/fern/components/voice-widget-rows/index.tsx b/fern/components/voice-widget-rows/index.tsx new file mode 100644 index 0000000000..77fb8bb4de --- /dev/null +++ b/fern/components/voice-widget-rows/index.tsx @@ -0,0 +1,462 @@ +import { memo, useCallback, useDeferredValue, useEffect, useMemo, useRef, useState } from "react"; +import type { ReactNode } from "react"; +import type { VoiceRow } from "../voice-widget/types"; +import { Skeleton } from "../skeleton/index"; +import { + // Data layer + display-time helpers reused VERBATIM from the cards widget so the two previews + // stay behaviorally identical (no logic drift): same fetch/cache, name cleaning, friendly + // language, gender normalization, no-preview filtering, toolbar/pager UI, breakpoint. The model + // string is shown verbatim (no remapping), matching the cards. + ALL, + DEFAULT_PAGE_SIZE, + MOBILE_PAGE_SIZE, + PAGE_SIZE_OPTIONS, + Select, + cleanName, + friendlyLanguage, + loadBundle, + modelKeyOf, + normalizeGender, + pageNumbers, + uniq, + useIsMobile, +} from "../voice-widget/index"; +import type { Row, VoiceWidgetProps } from "../voice-widget/index"; + +// Compact-ROWS preview of the TTS voice browser — "Direction B", a sibling to the cards +// (components/voice-widget). Modeled on the ElevenLabs voice picker: 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. It is a SEPARATE implementation that REUSES the cards +// module's data/display logic (imported above) and the same `.vw-*` design tokens — it only adds the +// row rendering + the row skeleton here. Same props surface as VoiceWidget so a page can swap one for +// the other. Styles live in this folder's styles.css (loaded via docs.yml `css:`). + +const ASSET_BASE = "https://mcdn.signalwire.com/voice_widget/dist"; // catalog.json + manifest.json +const AUDIO_BASE = "https://mcdn.signalwire.com"; // /audio//.mp3 + +type FilterKey = "search" | "provider" | "language" | "gender" | "group" | "pageSize"; + +// Same props as the cards (re-exported as an alias so pages can type either component identically). +export type VoiceWidgetRowsProps = VoiceWidgetProps; + +export function VoiceWidgetRows({ + 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, +}: VoiceWidgetRowsProps) { + // 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. (Same rules as the cards.) + 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. Identical to the cards' baseRows. + 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 ? ( + + ) : null; + + return ( +
+
+ ); +} + +// The loaded list, fading/rising into the slot the skeleton list held (no hard swap; reduced-motion- +// gated in CSS). Mirrors the cards' FadeGrid: 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 — see the cards module header). Same glyphs as the cards' info/copy icons. +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). Same glyphs as the cards. 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>(); + 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. +
  • +
    + + + {/* 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. Payload identical to the cards. */} + +
    +
  • + ); +}); + +// 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-rows/styles.css b/fern/components/voice-widget-rows/styles.css new file mode 100644 index 0000000000..7f120b15f4 --- /dev/null +++ b/fern/components/voice-widget-rows/styles.css @@ -0,0 +1,255 @@ +/* Non-Latin voice-name fallback (mirrors the cards). 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 (Direction B). A sibling to the cards (components/voice-widget); this + file styles only the 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 rows follow light/dark automatically — exactly the cards' token + approach: + 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 so it neither depends on nor collides with the cards' + stylesheet (both are loaded globally via docs.yml). 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. Shared `.vw-*` rules (head, toolbar pills, group toggle, pager, focus rings, tooltip) + are duplicated here scoped to .vw-rows so order/load of the cards CSS never matters. */ +.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 0; z-index: 2; } +.vw-rows .vw-select { display: flex; align-items: center; gap: .35rem; font-size: .8rem; color: var(--vw-muted); + background: var(--vw-card); border: 1px solid var(--vw-line); border-radius: 9px; padding: .4rem .7rem; } +.vw-rows .vw-select select { border: none; background: transparent; color: var(--vw-fg); font-weight: 600; + font-size: .8rem; padding: 0; cursor: pointer; } +/* 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. */ +.vw-rows .vw-row { 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/index.tsx b/fern/components/voice-widget/index.tsx index eb46767aa6..d29b4acae5 100644 --- a/fern/components/voice-widget/index.tsx +++ b/fern/components/voice-widget/index.tsx @@ -21,12 +21,15 @@ import { Skeleton } from "../skeleton/index"; 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; +// Exported (with the helpers/sub-components/types below) so the sibling rows preview +// (components/voice-widget-rows) reuses this module's data layer and display logic verbatim instead +// of forking it — keeping the two previews' behavior identical. See that file's header. +export const ALL = "__all__"; +export const DEFAULT_PAGE_SIZE = 48; +export 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]; +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, @@ -41,7 +44,7 @@ const PAGE_SIZE_OPTIONS = [4, 8, 12, 24, 48, 96]; // 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(). -function cleanName(displayName: string): string { +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(); @@ -51,7 +54,7 @@ function cleanName(displayName: string): string { // 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. -function friendlyLanguage(code: string): string { +export function friendlyLanguage(code: string): string { if (!code) return code; try { return new Intl.DisplayNames(["en"], { type: "language" }).of(code) || code; @@ -62,7 +65,7 @@ function friendlyLanguage(code: string): string { // 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. -function normalizeGender(gender: string): string { +export function normalizeGender(gender: string): string { switch (gender) { case "feminine": case "female": return "Female"; case "masculine": case "male": return "Male"; @@ -73,22 +76,21 @@ function normalizeGender(gender: string): string { } } - 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 }; +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). -const bundleCache = new Map>(); +export const bundleCache = new Map>(); -function loadBundle(catalogUrl: string, manifestUrl: string): Promise { +export function loadBundle(catalogUrl: string, manifestUrl: string): Promise { const cacheKey = `${catalogUrl}\n${manifestUrl}`; let cached = bundleCache.get(cacheKey); if (!cached) { @@ -128,7 +130,7 @@ function loadBundle(catalogUrl: string, manifestUrl: string): Promise { } // Tracks whether the viewport is in the mobile breakpoint (matches the CSS media query below). -function useIsMobile() { +export function useIsMobile() { const [mobile, setMobile] = useState(false); useEffect(() => { if (typeof window === "undefined" || !window.matchMedia) return; @@ -645,7 +647,7 @@ function VoiceWidgetSkeleton({ gridStyle, pills = 3 }: { gridStyle?: CSSProperti ); } -function Select({ label, value, onChange, opts }: +export function Select({ label, value, onChange, opts }: { label: string; value: string; onChange: (v: string) => void; opts: string[] }) { return (