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/voice-widget/index.tsx b/fern/components/voice-widget/index.tsx index fef88d3c57..eb46767aa6 100644 --- a/fern/components/voice-widget/index.tsx +++ b/fern/components/voice-widget/index.tsx @@ -1,5 +1,5 @@ import { memo, useCallback, useDeferredValue, useEffect, useMemo, useRef, useState } from "react"; -import type { CSSProperties } from "react"; +import type { CSSProperties, ReactNode } from "react"; import type { Catalog, Manifest, VoiceRow } from "./types"; import { Skeleton } from "../skeleton/index"; @@ -28,6 +28,52 @@ 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]; +// ── 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(). +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. +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. +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); + } +} + + type FilterKey = "search" | "provider" | "language" | "gender" | "group" | "pageSize"; // Client-side row: a catalog voice joined with its clip, plus two precomputed fields — `_search`, @@ -109,8 +155,10 @@ export interface VoiceWidgetProps { /** Cards rendered per page (page-size limit). Default: 48. Keeps the DOM small and the catalog fast. */ pageSize?: number; /** - * Fixed number of grid columns. Default: 3. Collapses to 1 column on phones regardless. Pass 0 - * for a responsive auto-fill grid (≈240px min per card) instead of a fixed count. + * Fixed number of grid columns. Default: 0 (a responsive equal-width auto-fill grid — 2-up in the + * docs prose column, 3-up only when the container is genuinely wide, every card a fixed ≥320px so + * none is squished). Pass a positive N to force exactly N equal columns. Collapses to 1 column on + * phones regardless. */ columns?: number; /** @@ -129,7 +177,8 @@ export interface VoiceWidgetProps { /** * Toggle the filter/search controls. `true` or unset shows all; `false` hides all (just the * title + grid). An object toggles individual controls — `search`, `provider`, `language`, - * `gender`, `group`, `pageSize` — each defaulting on unless set `false`. The provider control is + * `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>; @@ -142,7 +191,7 @@ export function VoiceWidget({ audioBaseUrl = AUDIO_BASE, groupBy: initialGroup = "provider", pageSize = DEFAULT_PAGE_SIZE, - columns = 3, + columns = 0, provider: lockedProvider, voiceIds, filters, @@ -183,16 +232,19 @@ export function VoiceWidget({ // single column is an excessive scroll on a phone. const isMobile = useIsMobile(); const effectivePageSize = isMobile ? Math.min(pageSizeChoice, MOBILE_PAGE_SIZE) : pageSizeChoice; - const pageSizeOpts = [...new Set([...PAGE_SIZE_OPTIONS, pageSize])].sort((a, b) => a - b); + const pageSizeOpts = [...new Set([...PAGE_SIZE_OPTIONS, pageSize])].sort((a, b) => a - b) + .filter((n) => !columns || columns <= 0 || n % columns === 0); // 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. + // 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" ? filters[key] !== false - : true; + : 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"); + const showGroup = group !== "none" && showFilter("group") && !lock; useEffect(() => { let alive = true; @@ -206,10 +258,15 @@ export function VoiceWidget({ // them once here instead of re-checking the full catalog inside the per-keystroke filter below. // Everything downstream — dropdown options, search, grouping, pagination — then works over only // the voices this embed can ever show (12 on the index, one provider's voices on a provider page). + // Also drops no-preview voices (missing/errored clip) here so every visible card is auditionable — + // no dimmed "no sample" cards. Defensive: the current bundle has 0 such voices (the catalog/ + // manifest delta is duplicate-key Rime rows sharing a clip). Caveat: if a provider's synthesis + // ever fails wholesale its voices silently drop from the widget — the pipeline's --diff flag + // catches that, and it beats showing broken cards. const baseRows = useMemo(() => { if (!allRows) return null; - if (!idAllowlist && !lock) return allRows; return allRows.filter((r) => + r.clip?.status === "ok" && (!idAllowlist || idAllowlist.has(r.voice_id.toLowerCase()) || idAllowlist.has(r.key.toLowerCase()) || @@ -324,23 +381,36 @@ export function VoiceWidget({ }, []); if (error) return
Failed to load voices: {error}
; - if (!baseRows) return ; + if (!baseRows) return ; // Step from safePage, not the raw page state: `page` can be stale-high after effectivePageSize // grows (a mobile→desktop resize isn't in filterSig), and stepping from it makes Prev appear // dead until the raw value catches back up with the clamped one. const pager = pageCount > 1 ? ( ) : null; @@ -348,14 +418,16 @@ export function VoiceWidget({