Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/docs/app/global.css
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
@source "../../../packages/code-block/src";
@source "../../../packages/color-picker/src";
@source "../../../packages/country-picker/src";
@source "../../../packages/model-picker/src";
@source "../content";

/**
Expand Down
115 changes: 115 additions & 0 deletions apps/docs/app/probe/model-picker/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
"use client"

/**
* Not a docs page — a viewing harness, kept out of `content/` so it never
* appears in the sidebar.
*
* The interesting states of this block are all *open* states: the panel, the
* details card beside it, the meters, a locked row. A headless screenshot
* cannot click, so the trigger is clicked programmatically once the page has
* settled, and `?state=` picks which arrangement to capture.
*
* /probe/model-picker?theme=dark
* /probe/model-picker?theme=light&state=filtered
* /probe/model-picker?state=locked
* /probe/model-picker?state=scrolled&y=34
*/

import { useEffect, useRef } from "react"
import { ModelPicker } from "@fern-ui/model-picker"
import {
ModelPickerDemo,
ModelPickerMinimal,
ModelPickerPill,
} from "@/components/demos/model-picker-demo"

/**
* Real key events, not React state — the point of the harness is to exercise
* the same path a user takes, including the focus and scroll behaviour that a
* prop-driven "open" would skip entirely.
*/
function press(target: Element, key: string) {
target.dispatchEvent(
new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true }),
)
}

export default function ModelPickerProbe() {
const hostRef = useRef<HTMLDivElement>(null)

useEffect(() => {
const state = new URLSearchParams(window.location.search).get("state")
const host = hostRef.current
if (!host) return

const trigger = host.querySelector<HTMLButtonElement>('[data-slot="trigger"]')
trigger?.click()

// One frame is not enough: the panel positions from a measurement taken
// after it mounts, and the details card only appears once that resolves.
const timer = window.setTimeout(() => {
const list = document.querySelector('[data-slot="list"]')
const input = document.querySelector<HTMLInputElement>('[data-slot="search"]')
const keyTarget = input ?? list
if (!keyTarget) return

// `scrolled` parks the list mid-scroll, which is where the section
// headings and the scroll fades have to be checked. `?y=` picks the
// offset. It sets scrollTop on a later tick and skips the arrow presses
// below, because the cursor's own scrollIntoView runs after them and
// snapped the list straight back — a scroll test that silently measured
// nothing.
if (state === "scrolled") {
window.setTimeout(() => {
const l = document.querySelector('[data-slot="list"]')
const y = new URLSearchParams(window.location.search).get("y")
if (l) l.scrollTop = Number(y ?? 70)
}, 200)
return
}

if (state === "locked") {
// The locked row sits last in the catalogue.
press(keyTarget, "End")
press(keyTarget, "ArrowUp")
} else if (state === "filtered") {
const chips = document.querySelectorAll<HTMLButtonElement>(
'[data-slot="facets"] button',
)
chips[0]?.click()
} else {
press(keyTarget, "ArrowDown")
press(keyTarget, "ArrowDown")
}
}, 400)

return () => window.clearTimeout(timer)
}, [])

return (
<main className="min-h-screen bg-[var(--fern-background,#fff)] p-10">
<div className="flex flex-col gap-10">
<section>
<p className="mb-3 text-xs text-[var(--fern-muted,#71717a)]">
open
</p>
<div ref={hostRef}>
<ModelPickerDemo />
</div>
</section>

{/* Closed states, so one shot shows both triggers against the panel. */}
<section className="mt-[420px] flex items-center gap-6">
<p className="text-xs text-[var(--fern-muted,#71717a)]">closed</p>
<ModelPickerPill />
<ModelPickerMinimal />
<ModelPicker
models={[{ id: "a", name: "Claude Opus 4.8", provider: "Anthropic" }]}
defaultValue="a"
disabled
/>
</section>
</div>
</main>
)
}
240 changes: 240 additions & 0 deletions apps/docs/components/demos/model-picker-demo.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,240 @@
"use client"

import { useState } from "react"
import { ModelPicker, type Model } from "@fern-ui/model-picker"

/**
* Real models, so the block is exercised against the names and mark shapes it
* will actually meet — invented ones hide the problems worth finding.
*
* The package ships no catalogue: one goes stale in weeks, and a wrong price in
* a component library is worse than no price. These figures are published list
* prices per million input tokens at the time of writing, here to make the
* layout honest rather than to be quoted.
*/
const mark = (slug: string) => `https://cdn.simpleicons.org/${slug}`

/**
* Two marks are served from `public/`, where the country picker's flags live.
*
* ChatGPT because OpenAI's mark was pulled from the simple-icons CDN — the
* failure any real catalogue eventually hits. The Wikimedia file is the full
* green tile, so it needs no `logoBackground`. Gemini because its mark is a
* white spark, illegible without the brand gradient behind it.
*/
const CHATGPT = "/model-logos/chatgpt.svg"
const GEMINI = "/model-logos/gemini.svg"
/** Gemini's own blue-to-red sweep, which is the mark's whole identity. */
const GEMINI_TILE = "linear-gradient(135deg, #4285F4 0%, #9B72CB 50%, #D96570 100%)"

const MODELS: Model[] = [
{
id: "claude-opus-4-8",
name: "Claude Opus 4.8",
provider: "Anthropic",
logo: mark("claude"),
group: "Frontier",
badge: "New",
description:
"Anthropic's most capable model. Holds a plan across long agentic runs without losing the thread.",
tags: ["vision", "reasoning", "tools", "long context"],
strengths: ["Long-horizon agentic work", "Careful with tools", "Strong at code"],
limitations: ["The most expensive option here", "Slower to first token"],
price: { amount: 5, symbol: "$", per: "1M" },
speed: 2,
quality: 5,
},
{
id: "claude-sonnet-5",
name: "Claude Sonnet 5",
provider: "Anthropic",
logo: mark("claude"),
group: "Frontier",
description:
"Most of Opus's judgement at a fraction of the latency. The one to start from.",
tags: ["vision", "reasoning", "tools", "long context"],
strengths: ["Fast enough for an interactive loop", "Strong at extraction"],
limitations: ["Gives up earlier than Opus on hard reasoning"],
price: { amount: 3, symbol: "$", per: "1M" },
speed: 4,
quality: 4,
},
{
id: "gpt-5-1",
name: "GPT-5.1",
provider: "OpenAI",
logo: CHATGPT,
group: "Frontier",
description: "OpenAI's flagship, with adjustable reasoning effort.",
tags: ["vision", "reasoning", "tools"],
strengths: ["Reasoning effort is tunable per call", "Broad tool ecosystem"],
limitations: ["Latency climbs sharply at high effort"],
price: { amount: 1.25, symbol: "$", per: "1M" },
speed: 3,
quality: 5,
},
{
id: "gemini-3-pro",
name: "Gemini 3 Pro",
provider: "Google",
logo: GEMINI,
logoBackground: GEMINI_TILE,
group: "Frontier",
description:
"The widest context window on offer, and native audio and video input.",
tags: ["vision", "reasoning", "tools", "long context"],
strengths: ["1M token context", "Handles audio and video natively"],
limitations: ["Slows noticeably past a few hundred thousand tokens"],
price: { amount: 2, symbol: "$", per: "1M" },
speed: 3,
quality: 4,
},
{
id: "claude-haiku-4-5",
name: "Claude Haiku 4.5",
provider: "Anthropic",
logo: mark("claude"),
group: "Fast and cheap",
description:
"Built for classification, routing, and anything you run a million times.",
tags: ["vision", "tools", "fast"],
strengths: ["Very low latency", "Cheap enough to run per request"],
limitations: ["Loses the thread on long multi-step work"],
price: { amount: 1, symbol: "$", per: "1M" },
speed: 5,
quality: 3,
},
{
id: "gpt-5-1-mini",
name: "GPT-5.1 mini",
provider: "OpenAI",
logo: CHATGPT,
group: "Fast and cheap",
description: "The small tier of GPT-5.1, for high-volume work.",
tags: ["vision", "tools", "fast"],
strengths: ["Cheapest hosted option here", "Reliable structured output"],
limitations: ["Weak on open-ended reasoning"],
price: { amount: 0.25, symbol: "$", per: "1M" },
speed: 5,
quality: 2,
},
{
id: "deepseek-v3",
name: "DeepSeek-V3",
provider: "DeepSeek",
logo: mark("deepseek"),
group: "Open weights",
description: "Open weights, self-hostable, and strong at code for the price.",
tags: ["reasoning", "tools", "open weights"],
strengths: ["Runs on your own hardware", "Predictable cost at volume"],
limitations: ["No vision", "Shorter context than the frontier models"],
price: { amount: 0.28, symbol: "$", per: "1M" },
speed: 3,
quality: 3,
},
{
id: "llama-4-maverick",
name: "Llama 4 Maverick",
provider: "Meta",
logo: mark("meta"),
group: "Open weights",
description: "Meta's open-weight multimodal model, widely mirrored by hosts.",
tags: ["vision", "tools", "open weights"],
strengths: ["Permissive licence", "Available from many providers"],
limitations: ["Behind the frontier on hard reasoning"],
price: { amount: 0.35, symbol: "$", per: "1M" },
speed: 4,
quality: 2,
},
{
id: "mistral-large",
name: "Mistral Large",
provider: "Mistral",
logo: mark("mistralai"),
group: "Open weights",
description: "European-hosted, with strong multilingual coverage.",
tags: ["reasoning", "tools"],
strengths: ["EU data residency", "Even across European languages"],
limitations: ["No vision"],
price: { amount: 2, symbol: "$", per: "1M" },
speed: 3,
quality: 3,
},
{
id: "o3-pro",
name: "o3-pro",
provider: "OpenAI",
logo: CHATGPT,
group: "Frontier",
description:
"Extended deliberate reasoning, for problems where being right matters more than being quick.",
tags: ["reasoning", "tools"],
strengths: ["Best results on hard proofs and long refactors"],
limitations: ["Can spend minutes before answering"],
price: { amount: 20, symbol: "$", per: "1M", from: true },
speed: 1,
quality: 5,
disabled: true,
disabledReason: "Available on the Scale plan. Your workspace is on Team.",
},
]

export function ModelPickerDemo() {
const [model, setModel] = useState("claude-sonnet-5")
return <ModelPicker models={MODELS} value={model} onChange={setModel} />
}

/** `showPrice` brings the figures back, on the rows and on the trigger. */
export function ModelPickerPrices() {
const [model, setModel] = useState("gpt-5-1")
return (
<ModelPicker models={MODELS} value={model} onChange={setModel} showPrice />
)
}

/** The compact trigger, as it would sit in a row of config controls. */
export function ModelPickerPill() {
const [model, setModel] = useState("gemini-3-pro")
return (
<ModelPicker
models={MODELS}
value={model}
onChange={setModel}
variant="pill"
/>
)
}

/** Recents pinned above the catalogue, and no capability chips. */
export function ModelPickerRecent() {
const [model, setModel] = useState("claude-haiku-4-5")
const [recent, setRecent] = useState(["claude-haiku-4-5", "gpt-5-1"])

return (
<ModelPicker
models={MODELS}
value={model}
recent={recent}
onChange={(id) => {
setModel(id)
// Short: a "recent" section long enough to scroll is the catalogue
// again.
setRecent((r) => [id, ...r.filter((x) => x !== id)].slice(0, 3))
}}
filterable={false}
/>
)
}

/** A short list: no search field, no chips, no detail column. */
export function ModelPickerMinimal() {
const [model, setModel] = useState("claude-haiku-4-5")
return (
<ModelPicker
models={MODELS.slice(4, 7)}
value={model}
onChange={setModel}
showDetails={false}
/>
)
}
12 changes: 12 additions & 0 deletions apps/docs/components/mdx.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,13 @@ import {
ColorPickerMinimal,
ColorPickerModel,
} from "@/components/demos/color-picker-demo"
import {
ModelPickerDemo,
ModelPickerMinimal,
ModelPickerPill,
ModelPickerPrices,
ModelPickerRecent,
} from "@/components/demos/model-picker-demo"

/**
* Components reachable from MDX without an import in every file. Demos are
Expand Down Expand Up @@ -64,6 +71,11 @@ export function getMDXComponents(components?: MDXComponents): MDXComponents {
CountryPickerDemo,
CountryPickerPriority,
CountryPickerPlain,
ModelPickerDemo,
ModelPickerPill,
ModelPickerPrices,
ModelPickerRecent,
ModelPickerMinimal,
...components,
}
}
1 change: 1 addition & 0 deletions apps/docs/content/docs/components/meta.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
"---Inputs---",
"color-picker",
"country-picker",
"model-picker",
"---Display---",
"code-block"
]
Expand Down
Loading
Loading