From be88c38ea23bc208abecce76e322cfa464e3ab3e Mon Sep 17 00:00:00 2001 From: CrackedResearcher <119443185+CrackedResearcher@users.noreply.github.com> Date: Thu, 23 Jul 2026 17:20:13 +0530 Subject: [PATCH 1/3] Add @fern-ui/model-picker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A model picker built on the country picker's architecture: portalled anchored panel, ranked search, keyboard model, scroll-edge fades. What it adds is the part specific to choosing a model — capability chips derived from your own tags, recents, locked models that stay visible and say why, and prices. Hover, selection, badge and fade treatments are taken from the country picker rather than reinvented, so the two read as one family. The one deliberate departure is that section headings do not pin: a pinned heading either lets the row behind show through it or guillotines that row at an invisible line, which is worth paying for 198 countries and not for a dozen models. No runtime dependencies beyond React. Types, price formatting and the meter scale ship as a separate entry so they can be imported without React. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/model-picker/LICENSE | 21 + packages/model-picker/README.md | 153 ++++++ packages/model-picker/package.json | 57 +++ packages/model-picker/src/chrome.tsx | 238 ++++++++++ packages/model-picker/src/details.tsx | 183 +++++++ packages/model-picker/src/empty.tsx | 70 +++ packages/model-picker/src/filters.ts | 56 +++ packages/model-picker/src/icons.tsx | 152 ++++++ packages/model-picker/src/index.ts | 4 + packages/model-picker/src/keyboard.ts | 89 ++++ packages/model-picker/src/model-picker.tsx | 449 ++++++++++++++++++ packages/model-picker/src/model.ts | 161 +++++++ packages/model-picker/src/parts.tsx | 338 +++++++++++++ packages/model-picker/src/search.ts | 127 +++++ packages/model-picker/src/styles.css | 19 + .../model-picker/src/use-anchored-position.ts | 83 ++++ packages/model-picker/src/use-model-list.ts | 56 +++ packages/model-picker/src/use-open-state.ts | 129 +++++ .../model-picker/src/use-panel-geometry.ts | 85 ++++ packages/model-picker/tsconfig.json | 18 + packages/model-picker/tsup.config.ts | 39 ++ scripts/verify-packages.mjs | 3 +- 22 files changed, 2529 insertions(+), 1 deletion(-) create mode 100644 packages/model-picker/LICENSE create mode 100644 packages/model-picker/README.md create mode 100644 packages/model-picker/package.json create mode 100644 packages/model-picker/src/chrome.tsx create mode 100644 packages/model-picker/src/details.tsx create mode 100644 packages/model-picker/src/empty.tsx create mode 100644 packages/model-picker/src/filters.ts create mode 100644 packages/model-picker/src/icons.tsx create mode 100644 packages/model-picker/src/index.ts create mode 100644 packages/model-picker/src/keyboard.ts create mode 100644 packages/model-picker/src/model-picker.tsx create mode 100644 packages/model-picker/src/model.ts create mode 100644 packages/model-picker/src/parts.tsx create mode 100644 packages/model-picker/src/search.ts create mode 100644 packages/model-picker/src/styles.css create mode 100644 packages/model-picker/src/use-anchored-position.ts create mode 100644 packages/model-picker/src/use-model-list.ts create mode 100644 packages/model-picker/src/use-open-state.ts create mode 100644 packages/model-picker/src/use-panel-geometry.ts create mode 100644 packages/model-picker/tsconfig.json create mode 100644 packages/model-picker/tsup.config.ts diff --git a/packages/model-picker/LICENSE b/packages/model-picker/LICENSE new file mode 100644 index 0000000..46236cf --- /dev/null +++ b/packages/model-picker/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 hyperwavelabs + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/model-picker/README.md b/packages/model-picker/README.md new file mode 100644 index 0000000..d0eab63 --- /dev/null +++ b/packages/model-picker/README.md @@ -0,0 +1,153 @@ +# @fern-ui/model-picker + +A model picker for React — search, capability filters, and a details card that +follows the keyboard. No runtime dependencies beyond React itself. + +```bash +bun add @fern-ui/model-picker +``` + +Import the stylesheet once, anywhere in your app: + +```tsx +import "@fern-ui/model-picker/styles.css" +``` + +The component is built from Tailwind utilities. Without the stylesheet the +markup is correct and nothing is styled — it fails quietly rather than loudly, +so it is worth checking first if a block looks wrong. + +Skip it only if you are copy-pasting the source into a Tailwind project of your +own, where your build generates the utilities already. + +## Usage + +The catalogue is yours — there is no bundled model list, because a model list +goes stale in weeks and yours is the only one that is true. + +```tsx +import { ModelPicker, type Model } from "@fern-ui/model-picker" + +const MODELS: Model[] = [ + { + id: "opus-4-8", + name: "Opus 4.8", + provider: "Anthropic", + description: "The strongest model here for long multi-step work.", + tags: ["vision", "reasoning", "200K context"], + strengths: ["Holds long context without drifting", "Careful with tools"], + limitations: ["Slower than the smaller models"], + price: { amount: 15, unit: "credit", per: "1M tokens" }, + speed: 2, + quality: 5, + }, + // … +] + +function Example() { + const [model, setModel] = useState("opus-4-8") + return +} +``` + +Uncontrolled works too — omit `value` and pass `defaultValue`. + +## What it does that a dropdown does not + +**The details card is driven by the cursor, not by hover.** Arrow through the +list and the card keeps up, so everything it says — what a model is good at, +what it costs, why it is locked — is reachable without a mouse. It picks the +side with room, and folds into the rows when there is no room either side. + +**The meters are relative to your catalogue.** `speed` and `quality` take any +scale you like and are normalised across the models you actually offer, because +"fast" has no meaning without a population. Cost is normalised only within the +models sharing a price denominator — credits-per-second and credits-per-image +are different quantities, and ranking one against the other tells a user +nothing they can act on. + +**Locked models stay visible.** A model gated behind a plan keeps its row and +its details, cannot be selected, and says why. A model nobody can see is a +model nobody can upgrade for. + +**Filters come from your tags.** The chips are derived, not configured, so they +cannot drift out of step with the catalogue. Chips that would empty the list +are disabled in place rather than removed — a chip row that reflows as you +click through it puts the next chip under a moving target. + +**Order is preserved.** The list is never sorted alphabetically; a curated +catalogue puts the model you want chosen first, and sorting throws that away. + +## Keyboard + +Arrows move, Home and End jump to the ends, PageUp and PageDown move by ten. +Enter selects, Escape closes, Tab closes and moves on. Backspace on an empty +query removes the last filter chip. The trigger opens on Enter, Space or +ArrowDown. + +## Props + +| Prop | Type | Default | Description | +| --- | --- | --- | --- | +| `models` | `Model[]` | — | The catalogue. Order is preserved. | +| `value` | `string` | — | Controlled selection, as a model id. | +| `defaultValue` | `string` | — | Initial selection when uncontrolled. | +| `onChange` | `(id, model) => void` | — | Fires with the id and the full record. | +| `onOpenChange` | `(open) => void` | — | Fires as the panel opens and closes. | +| `recent` | `string[]` | — | Ids pinned into a leading section, most recent first. | +| `recentLabel` | `string` | `"Recent"` | Heading for that section. | +| `variant` | `"default" \| "pill"` | `"default"` | Compact trigger for a config row. | +| `searchable` | `boolean` | over 7 models | Show the search field. | +| `filterable` | `boolean` | 2+ tags | Show the capability chips. | +| `showDetails` | `boolean` | `true` | The card beside the list. | +| `showLogos` | `boolean` | `true` | Provider marks, with a drawn monogram fallback. | +| `showPrice` | `boolean` | `true` | Price badges on rows and the trigger. | +| `placeholder` | `string` | `"Select a model"` | Trigger text with nothing selected. | +| `searchPlaceholder` | `string` | `"Search models"` | Placeholder inside the search field. | +| `disabled` | `boolean` | `false` | Block interaction and dim the control. | +| `label` | `string` | `"Model"` | Accessible name for the trigger and panel. | + +## The `Model` record + +```ts +interface Model { + id: string + name: string + provider?: string + logo?: string + description?: string + strengths?: string[] + limitations?: string[] + tags?: string[] // searchable, and the source of the filter chips + badge?: string // "New", "Beta" + price?: { amount: number; unit?: string; per?: string; from?: boolean } + speed?: number // any scale — normalised across the catalogue + quality?: number + group?: string // section heading + disabled?: boolean + disabledReason?: string // a locked row with no reason is a dead end +} +``` + +`price.from` renders "from N". Use it when the number is a floor rather than +the price — presenting a floor as an exact figure is something a user only +discovers on their invoice. + +Types, `formatPrice` and `meterScale` are importable without React: + +```ts +import { formatPrice, type Model } from "@fern-ui/model-picker/model" +``` + +## Theming + +Every colour reads a `--fern-` custom property with a literal fallback, so the +block themes automatically where those variables exist and still renders +correctly where they do not. + +`--fern-surface`, `--fern-foreground`, `--fern-muted`, `--fern-default`, +`--fern-default-hover`, `--fern-focus`, `--fern-overlay-shadow`. + +## Licence + +MIT diff --git a/packages/model-picker/package.json b/packages/model-picker/package.json new file mode 100644 index 0000000..ba67449 --- /dev/null +++ b/packages/model-picker/package.json @@ -0,0 +1,57 @@ +{ + "name": "@fern-ui/model-picker", + "version": "0.1.0", + "description": "An accessible, dependency-free AI model picker for React — search, capability filters, and a details card that follows the keyboard.", + "license": "MIT", + "type": "module", + "sideEffects": false, + "files": [ + "dist" + ], + "main": "./dist/index.cjs", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "require": "./dist/index.cjs" + }, + "./styles.css": "./dist/styles.css", + "./model": { + "types": "./dist/model.d.ts", + "import": "./dist/model.js", + "require": "./dist/model.cjs" + }, + "./package.json": "./package.json" + }, + "scripts": { + "build": "rm -rf dist && tsup && tailwindcss -i src/styles.css -o dist/styles.css --minify", + "dev": "tsup --watch", + "typecheck": "tsc --noEmit", + "prepublishOnly": "bun run build && cd ../.. && node scripts/verify-packages.mjs" + }, + "peerDependencies": { + "react": "^18.0.0 || ^19.0.0" + }, + "devDependencies": { + "@tailwindcss/cli": "^4.3.3", + "@types/react": "^19.0.8", + "react": "^19.0.0", + "tailwindcss": "^4.3.3", + "tsup": "^8.3.6", + "typescript": "^5.7.3" + }, + "keywords": [ + "react", + "model-picker", + "model-selector", + "ai", + "llm", + "combobox", + "accessible" + ], + "publishConfig": { + "access": "public" + } +} diff --git a/packages/model-picker/src/chrome.tsx b/packages/model-picker/src/chrome.tsx new file mode 100644 index 0000000..3c1199e --- /dev/null +++ b/packages/model-picker/src/chrome.tsx @@ -0,0 +1,238 @@ +"use client" + +/** The closed control, and the panel's search and filter chrome. */ + +import * as React from "react" +import type { Model } from "./model" +import { Logo, RowPrice } from "./parts" +import { ChevronIcon, CloseIcon, SearchIcon } from "./icons" + +const cn = (...classes: (string | false | undefined | null)[]) => + classes.filter(Boolean).join(" ") + +const SURFACE = "var(--fern-surface, #ffffff)" +const FG = "var(--fern-foreground, #18181b)" +const MUTED = "var(--fern-muted, #71717a)" + +export type TriggerVariant = "default" | "pill" + +/** + * The trigger. `pill` is the compact form for a config row, `default` is + * standalone. + * + * Neither opens on hover: that fires on every pass of the cursor across a dense + * row and means nothing at all on touch. The pill stays 40px — the floor for an + * interactive target does not move because a control got smaller. + */ +export const Trigger = React.forwardRef< + HTMLButtonElement, + { + id: string + popoverId: string + open: boolean + label: string + variant: TriggerVariant + selected?: Model + placeholder: string + showLogos: boolean + showPrice: boolean + disabled: boolean + focusRing: string + onToggle: () => void + } +>(function Trigger( + { + id, + popoverId, + open, + label, + variant, + selected, + placeholder, + showLogos, + showPrice, + disabled, + focusRing, + onToggle, + }, + ref, +) { + const pill = variant === "pill" + + return ( + + ) +}) + +/** The search field, with its icon overlaid rather than in a wrapper box. + * Markup and metrics are the country picker's, unchanged. */ +export const SearchField = React.forwardRef< + HTMLInputElement, + { + listId: string + activeId?: string + placeholder: string + value: string + onChange: (value: string) => void + onClear: () => void + focusRing: string + } +>(function SearchField( + { listId, activeId, placeholder, value, onChange, onClear, focusRing }, + ref, +) { + return ( +
+ + + + onChange(event.target.value)} + className={cn( + "h-10 w-full rounded-xl bg-[var(--fern-default,#ebebec)] pr-9 pl-9", + "text-[15px] text-[var(--fern-foreground,#18181b)] outline-hidden", + "placeholder:text-[var(--fern-muted,#71717a)]", + focusRing, + )} + /> + {value && ( + + )} +
+ ) +}) + +/** + * Capability chips, on one line that scrolls. Wrapping them stacked seven into + * a block taller than three model rows — chrome outweighing what it filters. + * The edge fades so a sliced chip reads as more to come, not as a clipping bug. + */ +export function FacetChips({ + facets, + active, + live, + onToggle, +}: { + facets: string[] + active: string[] + live: Set + onToggle: (tag: string) => void +}) { + if (!facets.length) return null + return ( +
+
+ {facets.map((tag) => { + const on = active.includes(tag) + const dead = !live.has(tag) + return ( + + ) + })} +
+ +
+ ) +} diff --git a/packages/model-picker/src/details.tsx b/packages/model-picker/src/details.tsx new file mode 100644 index 0000000..f087567 --- /dev/null +++ b/packages/model-picker/src/details.tsx @@ -0,0 +1,183 @@ +"use client" + +/** The column that grows out of the panel's edge. Driven by the cursor, so it + * is reachable with the keyboard — a hover-only submenu is not. */ + +import * as React from "react" +import { formatPrice, type Meters, type Model } from "./model" +import { LockIcon } from "./icons" + +const FG = "var(--fern-foreground, #18181b)" +const MUTED = "var(--fern-muted, #71717a)" +const ACCENT = "var(--fern-focus, #0485f7)" + +export const DETAIL_WIDTH = 296 +/** A model with nine listed strengths has, in practice, none. The catalogue's + * own order decides which three matter. */ +const MAX_BULLETS = 3 +const SEGMENTS = 5 + +/** + * Segments rather than a continuous bar: a bar at the bottom of the range reads + * as a half-loaded progress bar, where segments still say "one out of five". + * + * Never transitioned — an eased fill is still growing toward the previous model + * while the cursor is two rows on. + */ +function Meter({ label, value }: { label: string; value: number }) { + const filled = Math.max(1, Math.round(value * SEGMENTS)) + return ( +
+ + {label} + + + {Array.from({ length: SEGMENTS }, (_, i) => ( + + ))} + +
+ ) +} + +function Bullets({ items, sign }: { items: string[]; sign: "+" | "–" }) { + return ( +
    + {items.map((item) => ( +
  • + + {sign} + + {item} +
  • + ))} +
+ ) +} + +function Label({ children }: { children: React.ReactNode }) { + return ( +

+ {children} +

+ ) +} + +export function DetailsCard({ + model, + meters, + /** How many models the meters are relative to. */ + population, + logo, +}: { + model: Model + meters: Meters + population: number + logo: React.ReactNode +}) { + const price = formatPrice(model.price) + const hasMeters = + meters.speed != null || meters.quality != null || meters.cost != null + + return ( + // Hidden from assistive tech: all of it is already announced through the + // active row's label and description, and exposing it as well reads the + // name twice on every arrow press. Spacing is looser than the list, which + // is scanned rather than read. +
+
+ {logo} +
+

+ {model.name} +

+ {model.provider && ( +

+ {model.provider} +

+ )} +
+ {/* In the header, not a footer — as a footer it sits below the fold on + any model with three strengths. */} + {price && ( + + {price} + + )} +
+ + {model.disabled && ( +

+ + + + {model.disabledReason ?? "Not available on your current plan."} +

+ )} + + {model.description && ( +

+ {model.description} +

+ )} + + {hasMeters && ( +
+ {meters.quality != null && } + {meters.speed != null && } + {meters.cost != null && } + {/* Said out loud: a filled scale with no stated population reads as + an absolute score. */} +

+ Relative to the {population} models here +

+
+ )} + + {!!model.strengths?.length && ( +
+ + +
+ )} + + {!!model.limitations?.length && ( +
+ + +
+ )} + +
+ ) +} diff --git a/packages/model-picker/src/empty.tsx b/packages/model-picker/src/empty.tsx new file mode 100644 index 0000000..7dcef94 --- /dev/null +++ b/packages/model-picker/src/empty.tsx @@ -0,0 +1,70 @@ +"use client" + +import * as React from "react" +import { EmptyMark } from "./icons" + +const FG = "var(--fern-foreground, #18181b)" +const MUTED = "var(--fern-muted, #71717a)" + +/** + * Names what would match, rather than stopping at "no results". The suggestions + * are real facets from the catalogue, so they are guaranteed to return + * something — a dead end offering a second dead end is worse than the first. + */ +export function EmptyState({ + query, + facets, + onReset, +}: { + query: string + facets: string[] + onReset: () => void +}) { + return ( +
+ + + +

+ {query.trim() + ? `No models match “${query.trim()}”` + : "No models match these filters"} +

+

+ {facets.length ? ( + <> + Try a provider, or a capability like{" "} + {facets.slice(0, 2).map((tag, index) => ( + + {index > 0 && " or "} + {tag} + + ))} + . + + ) : ( + "Try a model name, or the provider that makes it." + )} +

+ +
+ ) +} diff --git a/packages/model-picker/src/filters.ts b/packages/model-picker/src/filters.ts new file mode 100644 index 0000000..0e7d80b --- /dev/null +++ b/packages/model-picker/src/filters.ts @@ -0,0 +1,56 @@ +import type { Model } from "./model" + +/** + * Derived from the catalogue, not configured — a separate `filters` prop goes + * stale the moment a model gains a tag, and fails invisibly. + * + * Ordered by how many models carry each tag: a facet matching one model is a + * search, not a filter. + */ +export function facets(models: Model[]): string[] { + const counts = new Map() + const firstSeen = new Map() + for (const [index, model] of models.entries()) { + for (const tag of model.tags ?? []) { + counts.set(tag, (counts.get(tag) ?? 0) + 1) + if (!firstSeen.has(tag)) firstSeen.set(tag, index) + } + } + return [...counts.keys()].sort( + (a, b) => + counts.get(b)! - counts.get(a)! || firstSeen.get(a)! - firstSeen.get(b)!, + ) +} + +/** + * Facets narrow, they do not widen. Picking "vision" then "fast" is someone + * building up a requirement; answering with a longer list than they started + * from reads as the control ignoring them. + */ +export function applyFacets(models: Model[], active: string[]): Model[] { + if (!active.length) return models + return models.filter((model) => + active.every((tag) => model.tags?.includes(tag)), + ) +} + +/** + * Which facets still return something. Dead ones are disabled rather than + * hidden — a chip row that reflows as you click through it puts the next chip + * under a moving target. + */ +export function liveFacets( + models: Model[], + active: string[], + all: string[], +): Set { + const live = new Set() + for (const tag of all) { + if (active.includes(tag)) { + live.add(tag) + continue + } + if (applyFacets(models, [...active, tag]).length) live.add(tag) + } + return live +} diff --git a/packages/model-picker/src/icons.tsx b/packages/model-picker/src/icons.tsx new file mode 100644 index 0000000..edf25c7 --- /dev/null +++ b/packages/model-picker/src/icons.tsx @@ -0,0 +1,152 @@ +/** Inline SVGs — the package ships no icon dependency. */ + +const iconProps = { + viewBox: "0 0 24 24", + fill: "none", + stroke: "currentColor", + strokeWidth: 2, + strokeLinecap: "round" as const, + strokeLinejoin: "round" as const, + "aria-hidden": true, +} + +export function SearchIcon() { + return ( + + + + + ) +} + +export function ChevronIcon({ open }: { open: boolean }) { + return ( + + + + ) +} + +export function CheckIcon({ size = 15 }: { size?: number }) { + return ( + + + + ) +} + +export function CloseIcon({ size = 12 }: { size?: number }) { + return ( + + + + ) +} + +/** Locked rows. A shackle that reads at 12px — closed, not ajar. */ +export function LockIcon({ size = 12 }: { size?: number }) { + return ( + + + + + ) +} + +/** Strengths. */ +export function PlusIcon({ size = 13 }: { size?: number }) { + return ( + + + + ) +} + +/** Limitations. Deliberately a minus rather than a warning triangle — a + * tradeoff is not an alert, and the triangle made every model look broken. */ +export function MinusIcon({ size = 13 }: { size?: number }) { + return ( + + + + ) +} + +/** + * Empty-state mark: two tilted cards behind a chip. + * + * Drawn rather than shipped as an image — the packages ship no assets, and a + * raster mark cannot follow the theme. Colours are tints of the foreground so + * they move the right way on both: darker on light, lighter on dark. + */ +export function EmptyMark() { + const CARD = 100 + const scale = 0.6 + const tint = (percent: number) => + `color-mix(in oklab, var(--fern-foreground, #18181b) ${percent}%, var(--fern-surface, #ffffff))` + + return ( +
+
+
+
+ + + + +
+
+ ) +} diff --git a/packages/model-picker/src/index.ts b/packages/model-picker/src/index.ts new file mode 100644 index 0000000..d0c9077 --- /dev/null +++ b/packages/model-picker/src/index.ts @@ -0,0 +1,4 @@ +export { ModelPicker } from "./model-picker" +export type { ModelPickerProps } from "./model-picker" +export { formatPrice, meterScale } from "./model" +export type { Meters, Model, ModelPrice } from "./model" diff --git a/packages/model-picker/src/keyboard.ts b/packages/model-picker/src/keyboard.ts new file mode 100644 index 0000000..70a4a2c --- /dev/null +++ b/packages/model-picker/src/keyboard.ts @@ -0,0 +1,89 @@ +import type * as React from "react" + +export interface KeyContext { + open: boolean + disabled: boolean + count: number + /** Empty query, used to decide whether Backspace belongs to the text. */ + queryEmpty: boolean + setOpen: (open: boolean) => void + setActiveIndex: React.Dispatch> + select: () => void + /** Removes the last active facet chip. */ + popFacet: () => void + close: () => void +} + +/** + * Arrows move one, Page keys ten, Home and End jump. Enter selects, Escape + * closes, Tab closes and moves on. + * + * Backspace on an empty query removes the last facet chip, as every token input + * does. Guarded on the query being empty — deleting a filter mid-word is a + * worse failure than not offering the shortcut. + */ +export function handleKeys( + event: React.KeyboardEvent, + { + open, + disabled, + count, + queryEmpty, + setOpen, + setActiveIndex, + select, + popFacet, + close, + }: KeyContext, +) { + if (disabled) return + + if (!open) { + if (["ArrowDown", "Enter", " "].includes(event.key)) { + event.preventDefault() + setOpen(true) + } + return + } + + switch (event.key) { + case "ArrowDown": + event.preventDefault() + setActiveIndex((i) => Math.min(i + 1, count - 1)) + break + case "ArrowUp": + event.preventDefault() + setActiveIndex((i) => Math.max(i - 1, 0)) + break + case "Home": + event.preventDefault() + setActiveIndex(0) + break + case "End": + event.preventDefault() + setActiveIndex(count - 1) + break + case "PageDown": + event.preventDefault() + setActiveIndex((i) => Math.min(i + 10, count - 1)) + break + case "PageUp": + event.preventDefault() + setActiveIndex((i) => Math.max(i - 10, 0)) + break + case "Enter": + event.preventDefault() + select() + break + case "Backspace": + if (queryEmpty) popFacet() + break + case "Escape": + event.preventDefault() + close() + break + case "Tab": + close() + break + } +} diff --git a/packages/model-picker/src/model-picker.tsx b/packages/model-picker/src/model-picker.tsx new file mode 100644 index 0000000..714cc4a --- /dev/null +++ b/packages/model-picker/src/model-picker.tsx @@ -0,0 +1,449 @@ +"use client" + +/** + * The picker's shell. Matching is in `./search`, faceting in `./filters`, the + * record and its metrics in `./model`, the visible list in `./use-model-list`, + * geometry in `./use-panel-geometry`, glyphs in `./icons`, leaves in `./parts` + * and `./empty`, trigger and chrome in `./chrome`, the detail pane in + * `./details`. + */ + +import * as React from "react" +import { createPortal } from "react-dom" +import type { Model } from "./model" +import { handleKeys } from "./keyboard" +import { useOpenState, useScrollEdges } from "./use-open-state" +import { useAnchoredPosition } from "./use-anchored-position" +import { usePanelGeometry } from "./use-panel-geometry" +import { useModelList } from "./use-model-list" +import { Logo, Row, ScrollFade, SectionHeader } from "./parts" +import { EmptyState } from "./empty" +import { FacetChips, SearchField, Trigger, type TriggerVariant } from "./chrome" +import { DETAIL_WIDTH, DetailsCard } from "./details" + +const cn = (...classes: (string | false | undefined | null)[]) => + classes.filter(Boolean).join(" ") + +const HAIRLINE = + "color-mix(in oklab, var(--fern-foreground, #18181b) 9%, transparent)" + +export interface ModelPickerProps + extends Omit, "onChange" | "onSelect"> { + /** The catalogue. Order is preserved — put the model you want chosen first. */ + models: Model[] + /** Controlled selection, as a model id. */ + value?: string + /** Initial selection when uncontrolled. */ + defaultValue?: string + /** Fires with the id and the full model record. */ + onChange?: (id: string, model: Model) => void + onOpenChange?: (open: boolean) => void + /** + * Ids pinned into a leading section, most recent first. Persisting them is + * the host's decision, not the component's — a block that writes to + * localStorage on its own is a surprise in someone else's app. + */ + recent?: string[] + recentLabel?: string + /** Compact trigger for a config row, or the standalone control. */ + variant?: TriggerVariant + /** Text shown on the trigger with nothing selected. */ + placeholder?: string + searchPlaceholder?: string + /** Defaults on once the catalogue is long enough to need it. */ + searchable?: boolean + /** Capability chips derived from tags. Defaults on when there are tags. */ + filterable?: boolean + /** + * A second pane with the active model's description, meters and tradeoffs. + * + * Off by default: the list alone answers the question most of the time. Where + * it is on it is open from the moment the panel is — no hover, no reveal + * delay — and folds into the rows when the viewport cannot hold both. + */ + showDetails?: boolean + showLogos?: boolean + /** + * Price beside each name, and on the trigger. Off by default: a column of + * prices reframes the list as a menu, which is a stronger claim than a + * component should make on its own. + */ + showPrice?: boolean + disabled?: boolean + /** Accessible name for the trigger and the panel. */ + label?: string +} + +export const ModelPicker = React.forwardRef( + function ModelPicker( + { + models, + value, + defaultValue, + onChange, + onOpenChange, + recent, + recentLabel = "Recent", + variant = "default", + placeholder = "Select a model", + searchPlaceholder = "Search models", + searchable, + filterable, + showDetails = false, + showLogos = true, + showPrice = false, + disabled = false, + label = "Model", + className, + ...props + }, + forwardedRef, + ) { + const [open, setOpen] = React.useState(false) + const { rendered, shown, reducedMotion, panelRef: attachPanel } = + useOpenState(open) + const [query, setQuery] = React.useState("") + const [activeFacets, setActiveFacets] = React.useState([]) + const [activeIndex, setActiveIndex] = React.useState(0) + const [internal, setInternal] = React.useState(defaultValue) + + const selectedId = value ?? internal + const listId = React.useId() + const triggerId = React.useId() + const popoverId = React.useId() + + const rootRef = React.useRef(null) + const triggerRef = React.useRef(null) + const inputRef = React.useRef(null) + const listRef = React.useRef(null) + const panelRef = React.useRef(null) + + const position = useAnchoredPosition(triggerRef, rendered, 420) + const geometry = usePanelGeometry( + position, + DETAIL_WIDTH, + showDetails, + shown, + reducedMotion, + ) + const { edges, measure: measureEdges } = useScrollEdges(listRef, rendered && !!geometry) + + const selected = models.find((m) => m.id === selectedId) + const { items, facets, live, meters } = useModelList( + models, + query, + activeFacets, + recent, + recentLabel, + ) + + const showSearch = searchable ?? models.length > 7 + const showFacets = (filterable ?? facets.length >= 2) && facets.length > 0 + + // Reset the cursor when the result set changes, or it can point past the end. + const [lastKey, setLastKey] = React.useState("") + const resultKey = `${query}|${activeFacets.join(",")}` + if (resultKey !== lastKey) { + setLastKey(resultKey) + setActiveIndex(0) + } + + const activeModel = items[activeIndex]?.model + + /** + * One element per section, each carrying its own heading. + * + * `index` stays flat across sections — it is the keyboard cursor's position + * in `items` and must not restart per group. + */ + const groups = React.useMemo(() => { + const out: { heading?: string; rows: { model: Model; index: number }[] }[] = + [] + items.forEach((item, index) => { + if (item.heading || out.length === 0) { + out.push({ heading: item.heading, rows: [] }) + } + out[out.length - 1]!.rows.push({ model: item.model, index }) + }) + return out + }, [items]) + // The pane holds its width even with nothing to show. Letting the panel + // narrow on an empty search and widen again on the next keystroke makes the + // whole object twitch under a cursor that has not moved. + const withDetail = !!geometry?.withDetail + + const commit = (model: Model) => { + // Locked rows keep the cursor so the pane can explain them, but a click + // must not quietly do nothing that looks like a selection. + if (model.disabled) return + if (value === undefined) setInternal(model.id) + onChange?.(model.id, model) + setOpen(false) + onOpenChange?.(false) + setQuery("") + } + + const setOpenState = (next: boolean) => { + setOpen(next) + onOpenChange?.(next) + } + + const close = () => { + setOpenState(false) + setQuery("") + } + + // Close on an outside pointer. The panel is portalled, so it is not inside + // rootRef and has to be checked separately. + React.useEffect(() => { + if (!open) return + const onPointer = (event: PointerEvent) => { + const target = event.target as Node + if (rootRef.current?.contains(target)) return + if (panelRef.current?.contains(target)) return + close() + } + document.addEventListener("pointerdown", onPointer) + return () => document.removeEventListener("pointerdown", onPointer) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open]) + + // Focus on open, and start the cursor on the current selection rather than + // row 0 — opening a list scrolled away from what is chosen makes someone + // hunt for the thing they already picked. + React.useEffect(() => { + if (!open) return + if (showSearch) inputRef.current?.focus() + else listRef.current?.focus() + const index = items.findIndex((item) => item.model.id === selectedId) + setActiveIndex(index > -1 ? index : 0) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open]) + + // Keep the active row in view. `block: "nearest"` so it scrolls the minimum + // rather than yanking the row to the centre on every arrow press. + React.useEffect(() => { + if (!open) return + listRef.current + ?.querySelector(`[data-index="${activeIndex}"]`) + ?.scrollIntoView({ block: "nearest" }) + }, [activeIndex, open, position?.maxHeight]) + + const onKeyDown = (event: React.KeyboardEvent) => + handleKeys(event, { + open, + disabled, + count: items.length, + queryEmpty: query === "", + setOpen: setOpenState, + setActiveIndex, + select: () => { + const model = items[activeIndex]?.model + if (model) commit(model) + }, + popFacet: () => setActiveFacets((f) => f.slice(0, -1)), + close, + }) + + const focusRing = + "outline-none focus-visible:ring-[3px] focus-visible:ring-[var(--fern-focus,#0485f7)]/50 focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--fern-surface,#ffffff)]" + + // With no room for the second pane the descriptions fold into the rows — + // narrower, but nothing is hidden behind the thing that explains it. + const inlineDetails = showDetails && !!geometry && !geometry.withDetail + + return ( +
{ + rootRef.current = node + if (typeof forwardedRef === "function") forwardedRef(node) + else if (forwardedRef) forwardedRef.current = node + }} + data-slot="model-picker" + className={cn( + "relative", + variant === "pill" ? "inline-flex" : "w-80", + className, + )} + onKeyDown={onKeyDown} + {...props} + > + setOpenState(!open)} + /> + + {rendered && + position && + geometry && + createPortal( +
{ + panelRef.current = node + attachPanel(node) + }} + id={popoverId} + data-slot="popover" + data-placement={position.placement} + role="dialog" + aria-label={label} + // One surface split by a hairline, not two slabs with a gutter: + // a gutter has no radius for the panes to be concentric with. + className="fixed z-50 flex overflow-hidden rounded-2xl bg-[var(--fern-surface,#ffffff)] p-1" + style={{ + ...geometry.vertical, + left: geometry.left, + width: geometry.width, + // Scales from the trigger it belongs to, not its own centre. + transformOrigin: + position.placement === "bottom" ? "top center" : "bottom center", + ...geometry.surface, + }} + > +
+ + {showSearch && ( +
+ { + setQuery("") + inputRef.current?.focus() + }} + focusRing={focusRing} + /> +
+ )} + + + {showFacets && ( +
+ + setActiveFacets((f) => + f.includes(tag) + ? f.filter((t) => t !== tag) + : [...f, tag], + ) + } + /> +
+ )} + + {/* min-h-0 is what makes the list the part that scrolls. */} +
+ {edges.top && } +
+ {items.length === 0 ? ( + { + setQuery("") + setActiveFacets([]) + inputRef.current?.focus() + }} + /> + ) : ( + groups.map((group, groupIndex) => ( +
+ {group.heading && ( + + )} + {group.rows.map(({ model, index }) => ( + + ))} +
+ )) + )} +
+ {edges.bottom && } +
+
+ + {withDetail && ( +
+ {activeModel && ( + } + /> + )} +
+ )} +
, + document.body, + )} +
+ ) + }, +) diff --git a/packages/model-picker/src/model.ts b/packages/model-picker/src/model.ts new file mode 100644 index 0000000..a9e2b00 --- /dev/null +++ b/packages/model-picker/src/model.ts @@ -0,0 +1,161 @@ +/** + * The model record, its price formatting, and the catalogue-relative metrics + * behind the meters. No React, so a server component — or a pricing page, or a + * test — can import this without pulling the picker in. That is why it is its + * own entry in `tsup.config.ts` and in `exports`. + */ + +export interface ModelPrice { + amount: number + /** + * Written before the figure — "$4/1M". With one, the unit noun is dropped + * everywhere: "$4" says what "4 dollars" says in a third of the width. + */ + symbol?: string + /** + * Singular. Pluralised naively with an "s" at 2 or more, which is correct + * for "credit", "token" and "request" — pass an already-plural noun if your + * unit is irregular. + */ + unit?: string + /** + * The denominator, written short: "sec", "image", "1M tokens". It lands in a + * badge beside the model name, so "generation" pushes the name out of a + * narrow trigger where "image" does not. + */ + per?: string + /** + * Renders "from N", for a figure that is a floor rather than the price. + * Presenting a floor as exact is something a user discovers on their invoice. + */ + from?: boolean +} + +export interface Model { + /** Stable id. This is what `onChange` reports and `value` matches against. */ + id: string + /** What the user reads. */ + name: string + /** Shown under the name and searchable — "Anthropic", "Google". */ + provider?: string + /** + * Provider mark. Optional by design: a model without one falls back to its + * initial on the same tile, never to another provider's icon. + */ + logo?: string + /** + * The tile behind the mark — any CSS background, gradients included. For a + * monochrome mark that would otherwise be invisible on one theme: pair it + * with a white version of the logo. + */ + logoBackground?: string + /** One or two lines. The reason someone would pick this model. */ + description?: string + strengths?: string[] + limitations?: string[] + /** Capabilities. Searchable, and the source of the filter chips. */ + tags?: string[] + /** A short status word beside the name: "New", "Beta", "Preview". */ + badge?: string + price?: ModelPrice + /** + * Any scale you like — 1-5, tokens/sec, a percentage. Meters normalise + * against the rest of the catalogue, so only the ordering matters. + */ + speed?: number + quality?: number + /** Section heading. Groups appear in the order they first occur. */ + group?: string + /** + * Unavailable on this plan or for this input. The row stays visible and the + * cursor still lands on it — a model you cannot see is a model you cannot + * upgrade for — but it cannot be selected. + */ + disabled?: boolean + /** Why it is unavailable. A locked row with no reason is a dead end. */ + disabledReason?: string +} + +/** + * "from 4 credits/sec", "2 credits/image", "6 credits". + * + * One format for the badge and the details card both, so the number a user + * compares in the list is the same number they read after opening it. + */ +export function formatPrice(price: ModelPrice | undefined): string | null { + if (!price || !Number.isFinite(price.amount)) return null + const prefix = price.from ? "from " : "" + const per = price.per ? `/${price.per}` : "" + if (price.symbol) return `${prefix}${price.symbol}${price.amount}${per}` + const unit = price.unit ?? "credit" + const noun = price.amount === 1 ? unit : `${unit}s` + return `${prefix}${price.amount} ${noun}${per}` +} + +/** + * The row form, without the unit noun — the noun is identical on every line and + * costs more width than the model names have. The card spells it out in full. + */ +export function compactPrice(price: ModelPrice | undefined): string | null { + if (!price || !Number.isFinite(price.amount)) return null + const per = price.per ? `/${price.per}` : "" + return `${price.from ? "~" : ""}${price.symbol ?? ""}${price.amount}${per}` +} + +export interface Meters { + /** 0-1, or null when the catalogue gives nothing to compare against. */ + speed: number | null + quality: number | null + /** Already inverted: a full bar is cheap, because full always reads as good. */ + cost: number | null +} + +const normalise = (value: number, values: number[]): number | null => { + if (values.length < 2) return null + const min = Math.min(...values) + const max = Math.max(...values) + // Every model scoring the same says nothing, and a row of full bars reads as + // a claim rather than the absence of one. + if (max === min) return null + return (value - min) / (max - min) +} + +/** + * A `model -> meters` lookup for one catalogue. + * + * Relative, not absolute: "fast" has no meaning without a population, and a + * fixed 1-5 tier invites a vendor to mark everything a 4. + * + * Cost is normalised only within models sharing a price denominator — + * per-second and per-image are different quantities, and ranking them against + * each other tells a user nothing they can act on. + */ +export function meterScale(models: Model[]): (model: Model) => Meters { + const speeds = models.map((m) => m.speed).filter((v): v is number => v != null) + const qualities = models + .map((m) => m.quality) + .filter((v): v is number => v != null) + + const byUnit = new Map() + for (const model of models) { + if (model.price == null) continue + const key = `${model.price.unit ?? "credit"}/${model.price.per ?? ""}` + if (!byUnit.has(key)) byUnit.set(key, []) + byUnit.get(key)!.push(model.price.amount) + } + + return (model) => { + let cost: number | null = null + if (model.price) { + const key = `${model.price.unit ?? "credit"}/${model.price.per ?? ""}` + const peers = byUnit.get(key) ?? [] + const ratio = normalise(model.price.amount, peers) + cost = ratio == null ? null : 1 - ratio + } + return { + speed: model.speed == null ? null : normalise(model.speed, speeds), + quality: model.quality == null ? null : normalise(model.quality, qualities), + cost, + } + } +} diff --git a/packages/model-picker/src/parts.tsx b/packages/model-picker/src/parts.tsx new file mode 100644 index 0000000..cb5a318 --- /dev/null +++ b/packages/model-picker/src/parts.tsx @@ -0,0 +1,338 @@ +"use client" + +/** The list's presentational leaves, kept out of the shell so the component + * file reads as composition. */ + +import * as React from "react" +import { compactPrice, type Model } from "./model" +import { matchRange } from "./search" +import { CheckIcon, LockIcon } from "./icons" + +const cn = (...classes: (string | false | undefined | null)[]) => + classes.filter(Boolean).join(" ") + +const SURFACE = "var(--fern-surface, #ffffff)" +const FG = "var(--fern-foreground, #18181b)" +const MUTED = "var(--fern-muted, #71717a)" + +/** + * Fade at a scroll boundary, so rows dissolve into the edge instead of being + * sliced by it. Anywhere content scrolls under something — a sticky header, the + * end of a panel — the hard cut reads as a rendering fault. + */ +export function ScrollFade({ side }: { side: "top" | "bottom" }) { + return ( +
+ ) +} + +/** + * Bolds the matched run inside a label. + * + * Weight rather than a background tint: the row already uses background to say + * where the cursor is and what is selected, and a third meaning painted in the + * same channel is one meaning too many. + */ +export function Highlight({ text, query }: { text: string; query: string }) { + const range = matchRange(text, query) + if (!range) return <>{text} + const [start, end] = range + return ( + <> + {text.slice(0, start)} + + {text.slice(start, end)} + + {text.slice(end)} + + ) +} + +/** + * The provider mark, drawn straight into the row — no plate or ring, which + * would read as a favicon pasted in and compete with the name beside it. + * + * A monochrome black mark is therefore invisible on a dark panel; nothing here + * can know what is inside the file, so `logoBackground` exists for brands that + * need a filled tile. A missing or broken logo falls back to the provider's + * initial rather than a shared placeholder, which would remove the one thing + * the eye uses to find a row again. + */ +export function Logo({ + model, + size = 22, + eager = false, +}: { + model: Model + size?: number + eager?: boolean +}) { + const [failed, setFailed] = React.useState(false) + + // Reset when the model changes, or the trigger keeps a previous model's + // failure and shows an initial for a logo that would have loaded. + const [lastId, setLastId] = React.useState(model.id) + if (lastId !== model.id) { + setLastId(model.id) + setFailed(false) + } + + const filled = !!model.logoBackground + const plate: React.CSSProperties = filled + ? { + background: model.logoBackground, + boxShadow: "inset 0 0 0 1px rgb(0 0 0 / 0.12)", + } + : {} + + if (model.logo && !failed) { + return ( + setFailed(true)} + data-slot="logo" + className="shrink-0 rounded-full object-contain" + style={{ + width: size, + height: size, + // A filled tile needs the mark inset off its own edge; a bare mark + // wants the whole box, or it renders smaller than its neighbours. + padding: filled ? size * 0.2 : 0, + ...plate, + }} + /> + ) + } + + return ( + + {(model.provider ?? model.name).trim().charAt(0).toUpperCase()} + + ) +} + +/** + * Built like the country picker's dial badge, so the two blocks read as one + * family. + * + * The fill is a tint of the foreground, not `--fern-default` — that is also the + * hovered-row background, and a badge painted in it disappears on exactly the + * row you are pointing at. + */ +export function RowPrice({ model }: { model: Model }) { + const label = compactPrice(model.price) + if (!label) return null + return ( + + {label} + + ) +} + +const ROW_SKIP = (tall: boolean): React.CSSProperties => ({ + // Lets the browser skip layout and paint for rows outside the scrollport, + // which is what keeps a long catalogue cheap without a virtualiser and its + // scroll-restoration problems. The intrinsic size supplies the height the + // row would have had, so the scrollbar does not jump as rows render. + contentVisibility: "auto", + containIntrinsicSize: tall ? "auto 56px" : "auto 40px", +}) + +export function Row({ + model, + index, + id, + query, + active, + selected, + showLogo, + showPrice, + /** No room for the detail column, so the row carries the description. */ + inlineDetails, + onSelect, + onHover, +}: { + model: Model + index: number + id: string + query: string + active: boolean + selected: boolean + showLogo: boolean + showPrice: boolean + inlineDetails: boolean + onSelect: (model: Model) => void + onHover: (index: number) => void +}) { + const locked = !!model.disabled + const sub = inlineDetails ? (model.description ?? model.provider) : undefined + + return ( +
onSelect(model)} + onPointerMove={() => onHover(index)} + style={ROW_SKIP(!!sub)} + className={cn( + // The country picker's row geometry exactly, so the two stacked in one + // app are the same object. `min-h-10` is the one addition: its 38px row + // is under fern's 40px floor for an interactive target. + "flex min-h-10 items-center gap-2.5 rounded-lg px-2 py-2", + // Two distinct states: the keyboard/pointer cursor is a neutral wash, + // the current selection is tinted with the accent — otherwise "where I + // am" and "what I chose" are indistinguishable. + active && !selected && "bg-[var(--fern-default,#ebebec)]", + selected && + "bg-[color-mix(in_oklab,var(--fern-focus,#0485f7)_12%,transparent)]", + selected && + active && + "bg-[color-mix(in_oklab,var(--fern-focus,#0485f7)_20%,transparent)]", + locked ? "cursor-not-allowed" : "cursor-pointer", + )} + > + {showLogo && ( + + + + )} + + + + + + + {model.badge && } + {/* Beside the name, like the badge — at the row's end it reads as a + status of the row rather than of the model. */} + {locked && ( + + + + )} + + {sub && ( + + {sub} + + )} + + + {showPrice && } + + {/* After the badge, as in the country picker. A reserved slot would keep + the badge column still but leave a permanent gap on every row. */} + {selected && ( + + + + )} + + {/* Read after the name when the cursor lands, so the reason a row cannot + be chosen reaches a screen reader at the moment it matters — the + detail column beside the list is visual reinforcement and is hidden. */} + {(locked || model.description) && ( + + {locked ? (model.disabledReason ?? "Unavailable") : model.description} + + )} +
+ ) +} + +/** Status word beside a name — "New", "Beta". Quiet enough not to outweigh the + * name it is attached to. */ +export function Badge({ label }: { label: string }) { + return ( + + {label} + + ) +} + +/** + * Sticky section heading. Its background fades out over the last stretch so a + * row scrolling underneath dissolves rather than being cut in half by the edge. + */ +/** + * Section heading. Deliberately not sticky, unlike the country picker's letter + * header. + * + * A pinned heading either lets the row behind show through it or guillotines + * that row at an invisible line, and two headings stack when the last section + * is too short to push the previous one out. That cost is worth paying for 198 + * countries, where losing the current letter loses your place; a catalogue of a + * dozen models is one flick from end to end. + */ +export function SectionHeader({ label }: { label: string }) { + return ( +
+ {label} +
+ ) +} diff --git a/packages/model-picker/src/search.ts b/packages/model-picker/src/search.ts new file mode 100644 index 0000000..5eae34a --- /dev/null +++ b/packages/model-picker/src/search.ts @@ -0,0 +1,127 @@ +import type { Model } from "./model" + +const fold = (value: string) => value.toLowerCase().trim() + +/** + * Scores a model against a query. Higher is better; 0 means no match. + * + * Ranked rather than filtered, so typing a provider gathers its models at the + * top instead of leaving them wherever the catalogue happened to put them. + */ +export function score(model: Model, query: string): number { + const q = fold(query) + if (!q) return 1 + + const name = fold(model.name) + const id = fold(model.id) + const provider = model.provider ? fold(model.provider) : "" + + if (id === q || name === q) return 100 + if (name.startsWith(q)) return 85 + // A provider match outranks a mid-name one: "google" should gather Google's + // models above a model that merely mentions it in a tag. + if (provider === q) return 80 + if (provider.startsWith(q)) return 70 + if (model.tags?.some((tag) => fold(tag) === q)) return 65 + if (name.includes(q)) return 50 + if (provider.includes(q)) return 40 + if (model.tags?.some((tag) => fold(tag).includes(q))) return 30 + if (id.includes(q)) return 20 + return 0 +} + +/** + * The visible list. + * + * With no query the catalogue's own order is preserved exactly. Unlike a + * country list, a model list is curated — the first entry is usually the one + * you want chosen, and alphabetising throws that away. Ties while searching + * break on the same original order, for the same reason. + */ +export function rank(models: Model[], query: string): Model[] { + if (!query.trim()) return models + return models + .map((model, index) => ({ model, index, s: score(model, query) })) + .filter((r) => r.s > 0) + .sort((a, b) => b.s - a.s || a.index - b.index) + .map((r) => r.model) +} + +/** + * Where a query lands in a string, so the row can bold it. + * + * Only the first occurrence, and only a contiguous run: highlighting every + * scattered letter of a fuzzy match turns the name into confetti and is harder + * to read than no highlight at all. + */ +export function matchRange(text: string, query: string): [number, number] | null { + const q = fold(query) + if (!q) return null + const start = fold(text).indexOf(q) + return start === -1 ? null : [start, start + q.length] +} + +export interface ListItem { + model: Model + /** Section heading to render above this row, if it opens one. */ + heading?: string +} + +/** + * Rows in section order, each section's heading attached to its first row. + * + * `recent` is pinned into its own leading section: in a catalogue big enough to + * need search, re-finding the two models someone alternates between is the most + * repeated action this component has. + */ +export function sections( + models: Model[], + recent: string[] | undefined, + recentLabel: string, +): ListItem[] { + const pinned: Model[] = [] + if (recent?.length) { + const byId = new Map(models.map((m) => [m.id, m] as const)) + for (const id of recent) { + const model = byId.get(id) + // Skipped rather than stubbed: a recents list outlives the catalogue it + // was recorded against. + if (model) pinned.push(model) + } + } + + const pinnedIds = new Set(pinned.map((m) => m.id)) + const rest = models.filter((m) => !pinnedIds.has(m.id)) + const grouped = rest.some((m) => m.group) + + const out: ListItem[] = pinned.map((model, index) => ({ + model, + heading: index === 0 ? recentLabel : undefined, + })) + + if (!grouped) { + // A single unnamed section still needs its heading when recents sit above + // it, or the rest of the catalogue reads as more recents. + for (const [index, model] of rest.entries()) { + out.push({ model, heading: index === 0 && pinned.length ? "All models" : undefined }) + } + return out + } + + const order: string[] = [] + const buckets = new Map() + for (const model of rest) { + const key = model.group ?? "Other" + if (!buckets.has(key)) { + buckets.set(key, []) + order.push(key) + } + buckets.get(key)!.push(model) + } + for (const key of order) { + for (const [index, model] of buckets.get(key)!.entries()) { + out.push({ model, heading: index === 0 ? key : undefined }) + } + } + return out +} diff --git a/packages/model-picker/src/styles.css b/packages/model-picker/src/styles.css new file mode 100644 index 0000000..514bd5c --- /dev/null +++ b/packages/model-picker/src/styles.css @@ -0,0 +1,19 @@ +/** + * The stylesheet shipped to consumers who install rather than copy-paste. + * + * Preflight is deliberately not imported. `@import "tailwindcss"` would pull + * Tailwind's base reset in with it, and a component library that resets its + * host's margins, headings and form controls is a bug, not a feature — only + * the theme variables and the utilities this component actually uses belong + * here. + * + * Built by @tailwindcss/cli into dist/styles.css. `@source` points at the + * component source so only the utilities in use are emitted. + */ + +@layer theme, base, components, utilities; + +@import "tailwindcss/theme.css" layer(theme); +@import "tailwindcss/utilities.css" layer(utilities); + +@source "./"; diff --git a/packages/model-picker/src/use-anchored-position.ts b/packages/model-picker/src/use-anchored-position.ts new file mode 100644 index 0000000..be04f2c --- /dev/null +++ b/packages/model-picker/src/use-anchored-position.ts @@ -0,0 +1,83 @@ +"use client" + +import * as React from "react" + +export interface AnchoredPosition { + top: number + left: number + width: number + /** Which way it opened, so the panel can scale from the right edge. */ + placement: "top" | "bottom" + maxHeight: number + /** + * Carried here rather than read from `window` at render, so the details card + * — which picks its side from the room left over — recomputes on resize off + * the same measurement pass as the panel, instead of holding a width from + * whenever it last happened to render. + */ + viewportWidth: number +} + +const GAP = 8 +const MARGIN = 12 + +/** + * Positions a panel against a trigger, in viewport coordinates. + * + * The panel is portalled to `document.body`, so an ancestor with + * `overflow: hidden` cannot clip it — inside a card, a preview box or a modal, + * an absolutely-positioned dropdown gets cut off, and no amount of z-index + * fixes it because clipping happens before stacking. + * + * Flips above the trigger when the space below cannot hold the panel, and + * reports the height actually available so the list can scroll rather than + * overflow the viewport. + */ +export function useAnchoredPosition( + triggerRef: React.RefObject, + open: boolean, + desiredHeight: number, +): AnchoredPosition | null { + const [position, setPosition] = React.useState(null) + React.useEffect(() => { + if (!open) return + const trigger = triggerRef.current + if (!trigger) return + + const measure = () => { + const rect = trigger.getBoundingClientRect() + const below = window.innerHeight - rect.bottom - GAP - MARGIN + const above = rect.top - GAP - MARGIN + // Only flip when below genuinely cannot hold it and above is roomier — + // flipping for a few pixels is more disorienting than a shorter list. + const flip = below < Math.min(desiredHeight, 240) && above > below + + setPosition({ + top: flip ? rect.top - GAP : rect.bottom + GAP, + left: rect.left, + width: rect.width, + placement: flip ? "top" : "bottom", + maxHeight: Math.max(160, Math.min(desiredHeight, flip ? above : below)), + viewportWidth: window.innerWidth, + }) + } + + measure() + // Measuring once is not enough: the trigger can still be settling as the + // panel opens, leaving it narrower than its own trigger until something + // else forces a re-measure. + const observer = new ResizeObserver(measure) + observer.observe(trigger) + // `capture` so it also fires for scrolls inside any ancestor container, + // which is the case that silently leaves the panel behind. + window.addEventListener("scroll", measure, true) + window.addEventListener("resize", measure) + return () => { + observer.disconnect() + window.removeEventListener("scroll", measure, true) + window.removeEventListener("resize", measure) + } + }, [open, desiredHeight, triggerRef]) + + return position +} diff --git a/packages/model-picker/src/use-model-list.ts b/packages/model-picker/src/use-model-list.ts new file mode 100644 index 0000000..8469452 --- /dev/null +++ b/packages/model-picker/src/use-model-list.ts @@ -0,0 +1,56 @@ +"use client" + +import * as React from "react" +import { meterScale, type Meters, type Model } from "./model" +import { rank, sections, type ListItem } from "./search" +import { applyFacets, facets as deriveFacets, liveFacets } from "./filters" + +export interface ModelList { + items: ListItem[] + facets: string[] + live: Set + meters: (model: Model) => Meters +} + +/** + * The list the panel renders: faceted, ranked, sectioned, and the facet state + * that goes with it. + * + * Kept out of the component so the "which models appear, in what order" rule + * is one testable pure pipeline rather than four memos interleaved with + * refs and portals. + */ +export function useModelList( + models: Model[], + query: string, + activeFacets: string[], + recent: string[] | undefined, + recentLabel: string, +): ModelList { + const facets = React.useMemo(() => deriveFacets(models), [models]) + + const live = React.useMemo( + () => liveFacets(models, activeFacets, facets), + [models, activeFacets, facets], + ) + + /** + * Meters scale against the whole catalogue, not the filtered view. Filtering + * to the three fastest models and then showing them as slow, medium and fast + * would rescale the bars under the user without anything having changed + * about the models themselves. + */ + const meters = React.useMemo(() => meterScale(models), [models]) + + const items = React.useMemo(() => { + const narrowed = applyFacets(models, activeFacets) + const ranked = rank(narrowed, query) + // Recents are a shortcut through the idle list. Once someone is searching + // or filtering they have said what they want, and pinning the same rows + // twice makes a short result set look longer than it is. + const idle = !query.trim() && !activeFacets.length + return sections(ranked, idle ? recent : undefined, recentLabel) + }, [models, activeFacets, query, recent, recentLabel]) + + return { items, facets, live, meters } +} diff --git a/packages/model-picker/src/use-open-state.ts b/packages/model-picker/src/use-open-state.ts new file mode 100644 index 0000000..03d8fdb --- /dev/null +++ b/packages/model-picker/src/use-open-state.ts @@ -0,0 +1,129 @@ +"use client" + +import * as React from "react" + +/** + * A softer ease-out than the usual (0.23, 1, 0.32, 1) — it decelerates over a + * longer tail, so the panel settles instead of snapping to a stop. + * + * Still ease-out, not ease-in. ease-in withholds movement for the first + * frames, which is precisely when the user is watching for a response, so it + * reads as lag however smooth the rest of the curve is. + */ +export const EASE_OUT = "cubic-bezier(0.16, 1, 0.3, 1)" +/** Enter has room to settle; exits stay quick, or leaving reads as reluctance. */ +export const ENTER_MS = 220 +export const EXIT_MS = 130 + +const REDUCED_MOTION = "(prefers-reduced-motion: reduce)" + +export function usePrefersReducedMotion() { + return React.useSyncExternalStore( + (cb) => { + const q = window.matchMedia(REDUCED_MOTION) + q.addEventListener("change", cb) + return () => q.removeEventListener("change", cb) + }, + () => window.matchMedia(REDUCED_MOTION).matches, + () => false, + ) +} + +/** + * Open/close with an exit transition. + * + * `rendered` keeps the panel in the DOM through its exit; `shown` drives the + * transition. Without the split there is no exit animation, because React + * unmounts before it can play. + */ +export function useOpenState(open: boolean) { + const reducedMotion = usePrefersReducedMotion() + const [rendered, setRendered] = React.useState(false) + const [shown, setShown] = React.useState(false) + + React.useEffect(() => { + if (open) { + setRendered(true) + return + } + setShown(false) + const timer = window.setTimeout( + () => setRendered(false), + reducedMotion ? 0 : EXIT_MS, + ) + return () => window.clearTimeout(timer) + }, [open, reducedMotion]) + + /** + * Attach to the panel. A ref callback runs with the node already in the DOM, + * so reading a layout property here forces the closed style to be committed + * before the open one is applied — which is what gives the transition a start + * state. Scheduling that with requestAnimationFrame instead is fragile: the + * frame can be cancelled by an effect re-run and the panel then mounts stuck + * at its closed style, invisible. + */ + const panelRef = React.useCallback( + (node: HTMLElement | null) => { + if (!node) return + void node.offsetHeight + setShown(true) + }, + [], + ) + + return { rendered, shown, reducedMotion, panelRef } +} + + +/** + * Which scroll edges have content beyond them. + * + * A fade that shows when there is nothing to scroll to is not a hint, it is a + * wash over the first row — so each edge reports independently and the fade + * only appears once it means something. + */ +export function useScrollEdges( + ref: React.RefObject, + /** + * True only once the scroller is in the DOM. `rendered` alone is a render too + * early — the panel waits for its anchor to resolve — so the effect runs + * against a null ref and never re-runs. + */ + active: boolean, +) { + const [edges, setEdges] = React.useState({ top: false, bottom: false }) + + const measure = React.useCallback(() => { + const el = ref.current + if (!el) return + const { scrollTop, scrollHeight, clientHeight } = el + const top = scrollTop > 1 + const bottom = scrollTop + clientHeight < scrollHeight - 1 + // Bail out when nothing changed. A fresh object is a new reference, so + // React re-renders, and the observer below re-measures after every render — + // an infinite loop, not a wasted render. + setEdges((prev) => + prev.top === top && prev.bottom === bottom ? prev : { top, bottom }, + ) + }, [ref]) + + /** + * Observed, not measured once after mount: a single measurement reads the + * list before flex has bounded it, `scrollHeight === clientHeight`, and no + * edge ever reports overflow. Nothing re-measures afterwards, because the + * only other trigger is a scroll on a list that looks complete. + */ + React.useEffect(() => { + const el = ref.current + if (!active || !el) return + measure() + const observer = new ResizeObserver(measure) + observer.observe(el) + // The scroller's own box stops changing once it is bounded; it is the + // content inside that grows and shrinks as results filter. + for (const child of Array.from(el.children)) observer.observe(child) + return () => observer.disconnect() + }, [active, measure, ref]) + + return { edges, measure } +} diff --git a/packages/model-picker/src/use-panel-geometry.ts b/packages/model-picker/src/use-panel-geometry.ts new file mode 100644 index 0000000..94de2d5 --- /dev/null +++ b/packages/model-picker/src/use-panel-geometry.ts @@ -0,0 +1,85 @@ +"use client" + +import * as React from "react" +import type { AnchoredPosition } from "./use-anchored-position" +import { EASE_OUT, ENTER_MS, EXIT_MS } from "./use-open-state" + +const MARGIN = 12 +/** + * Only ever widens a narrow trigger — a pill in a config row would otherwise + * open a 120px list. A standard trigger is wider than this, so the panel + * matches it exactly; narrower than its own control reads as misaligned. + */ +const MIN_LIST_WIDTH = 292 + +export interface PanelGeometry { + left: number + /** The whole panel, list plus detail column. */ + width: number + listWidth: number + /** Whether the detail column fits beside the list at all. */ + withDetail: boolean + vertical: React.CSSProperties + surface: React.CSSProperties +} + +/** + * Where the panel sits, and how it arrives. + * + * The detail column is part of the panel, not a second card beside it: two + * slabs with a gutter read as two windows, and the gutter has no radius for + * them to be concentric with. When the pair does not fit, the panel is simply + * the list and the descriptions fold into the rows. + */ +export function usePanelGeometry( + position: AnchoredPosition | null, + detailWidth: number, + wantsDetail: boolean, + shown: boolean, + reducedMotion: boolean, +): PanelGeometry | null { + return React.useMemo(() => { + if (!position) return null + + const listWidth = Math.max(position.width, MIN_LIST_WIDTH) + const available = position.viewportWidth - MARGIN * 2 + const withDetail = wantsDetail && listWidth + detailWidth <= available + const width = listWidth + (withDetail ? detailWidth : 0) + + return { + left: Math.max( + MARGIN, + Math.min(position.left, position.viewportWidth - MARGIN - width), + ), + width, + listWidth, + withDetail, + vertical: { + top: position.placement === "bottom" ? position.top : undefined, + bottom: + position.placement === "top" + ? window.innerHeight - position.top + : undefined, + // The bound goes on the panel; the list inside is a flex child that + // scrolls. Subtracting a guessed chrome height slices the last row the + // moment the chrome grows a line. + maxHeight: position.maxHeight, + }, + surface: { + boxShadow: + "var(--fern-overlay-shadow, 0 14px 28px 0 rgb(0 0 0 / 0.08), 0 2px 8px 0 rgb(0 0 0 / 0.06), 0 0 0 1px rgb(0 0 0 / 0.06))", + opacity: shown ? 1 : 0, + // Never from scale(0) — nothing in the real world appears out of + // nothing. 0.97 with a small rise reads as the panel arriving. + transform: reducedMotion + ? undefined + : shown + ? "scale(1) translateY(0)" + : `scale(0.97) translateY(${position.placement === "top" ? 6 : -6}px)`, + transitionProperty: reducedMotion ? "opacity" : "opacity, transform", + transitionDuration: `${shown ? ENTER_MS : EXIT_MS}ms`, + transitionTimingFunction: EASE_OUT, + }, + } + }, [position, detailWidth, wantsDetail, shown, reducedMotion]) +} diff --git a/packages/model-picker/tsconfig.json b/packages/model-picker/tsconfig.json new file mode 100644 index 0000000..db04394 --- /dev/null +++ b/packages/model-picker/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "moduleResolution": "Bundler", + "jsx": "react-jsx", + "strict": true, + "noUncheckedIndexedAccess": true, + "declaration": true, + "skipLibCheck": true, + "esModuleInterop": true, + "isolatedModules": true, + "verbatimModuleSyntax": true, + "noEmit": true + }, + "include": ["src"] +} diff --git a/packages/model-picker/tsup.config.ts b/packages/model-picker/tsup.config.ts new file mode 100644 index 0000000..0608660 --- /dev/null +++ b/packages/model-picker/tsup.config.ts @@ -0,0 +1,39 @@ +import { defineConfig } from "tsup" + +const shared = { + format: ["esm", "cjs"] as const, + dts: true, + external: ["react", "react-dom"], +} + +/** + * Two passes, because the client boundary is per entry and tsup's `banner` is + * per config. Bundling them together marks the model record and its price + * formatting as client code, which hands a server component a client reference + * instead of the function. + */ +export default defineConfig([ + { + ...shared, + entry: { index: "src/index.ts", "model-picker": "src/model-picker.tsx" }, + clean: false, + splitting: true, + /** + * esbuild strips directives when it bundles, so the boundary is re-added + * here — and `treeshake` stays off because it runs rollup over esbuild's + * output afterwards, which drops the directive again and warns that it + * "was ignored". That warning was the only sign nothing shipped a boundary. + */ + treeshake: false, + banner: { js: '"use client";' }, + }, + { + ...shared, + // Types, price formatting and the meter scale. No React, no banner — + // importing them on the server is the reason this is a separate entry. + entry: { model: "src/model.ts" }, + clean: false, + splitting: false, + treeshake: true, + }, +]) diff --git a/scripts/verify-packages.mjs b/scripts/verify-packages.mjs index 96d965e..758a2fa 100644 --- a/scripts/verify-packages.mjs +++ b/scripts/verify-packages.mjs @@ -16,7 +16,7 @@ import { readFileSync, existsSync, readdirSync } from "node:fs" import { join } from "node:path" -const PACKAGES = ["button", "code-block", "color-picker", "country-picker"] +const PACKAGES = ["button", "code-block", "color-picker", "country-picker", "model-picker"] /** * Entries that must NOT carry a client boundary. They exist so a consumer can @@ -29,6 +29,7 @@ const PACKAGES = ["button", "code-block", "color-picker", "country-picker"] const PURE_ENTRIES = { "color-picker": ["./color"], "country-picker": ["./countries"], + "model-picker": ["./model"], button: [], "code-block": [], } From abde7b7e8e1a0d7c774744eed5c764187aacc76e Mon Sep 17 00:00:00 2001 From: CrackedResearcher <119443185+CrackedResearcher@users.noreply.github.com> Date: Thu, 23 Jul 2026 17:20:13 +0530 Subject: [PATCH 2/3] Document the model picker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the docs page, a demo catalogue of real models, and a probe harness for the states a headless screenshot cannot reach by clicking — open, filtered, locked, and parked mid-scroll. The demo's marks are real: ChatGPT and Gemini are served from public/ the way the country picker's flags are, the rest from the simple-icons CDN. OpenAI's mark was pulled from that CDN, which is left visible rather than tidied away — it is the failure any real catalogue eventually hits, and it is what the initial-letter fallback exists for. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/docs/app/global.css | 1 + apps/docs/app/probe/model-picker/page.tsx | 115 +++++++ .../components/demos/model-picker-demo.tsx | 240 +++++++++++++++ apps/docs/components/mdx.tsx | 12 + apps/docs/content/docs/components/meta.json | 1 + .../content/docs/components/model-picker.mdx | 280 ++++++++++++++++++ apps/docs/next-env.d.ts | 2 +- apps/docs/package.json | 1 + apps/docs/public/model-logos/chatgpt.svg | 10 + apps/docs/public/model-logos/gemini.svg | 1 + 10 files changed, 662 insertions(+), 1 deletion(-) create mode 100644 apps/docs/app/probe/model-picker/page.tsx create mode 100644 apps/docs/components/demos/model-picker-demo.tsx create mode 100644 apps/docs/content/docs/components/model-picker.mdx create mode 100644 apps/docs/public/model-logos/chatgpt.svg create mode 100644 apps/docs/public/model-logos/gemini.svg diff --git a/apps/docs/app/global.css b/apps/docs/app/global.css index a58364b..ed095cd 100644 --- a/apps/docs/app/global.css +++ b/apps/docs/app/global.css @@ -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"; /** diff --git a/apps/docs/app/probe/model-picker/page.tsx b/apps/docs/app/probe/model-picker/page.tsx new file mode 100644 index 0000000..835a938 --- /dev/null +++ b/apps/docs/app/probe/model-picker/page.tsx @@ -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(null) + + useEffect(() => { + const state = new URLSearchParams(window.location.search).get("state") + const host = hostRef.current + if (!host) return + + const trigger = host.querySelector('[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('[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( + '[data-slot="facets"] button', + ) + chips[0]?.click() + } else { + press(keyTarget, "ArrowDown") + press(keyTarget, "ArrowDown") + } + }, 400) + + return () => window.clearTimeout(timer) + }, []) + + return ( +
+
+
+

+ open +

+
+ +
+
+ + {/* Closed states, so one shot shows both triggers against the panel. */} +
+

closed

+ + + +
+
+
+ ) +} diff --git a/apps/docs/components/demos/model-picker-demo.tsx b/apps/docs/components/demos/model-picker-demo.tsx new file mode 100644 index 0000000..ee9b077 --- /dev/null +++ b/apps/docs/components/demos/model-picker-demo.tsx @@ -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 +} + +/** `showPrice` brings the figures back, on the rows and on the trigger. */ +export function ModelPickerPrices() { + const [model, setModel] = useState("gpt-5-1") + return ( + + ) +} + +/** The compact trigger, as it would sit in a row of config controls. */ +export function ModelPickerPill() { + const [model, setModel] = useState("gemini-3-pro") + return ( + + ) +} + +/** 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 ( + { + 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 ( + + ) +} diff --git a/apps/docs/components/mdx.tsx b/apps/docs/components/mdx.tsx index 6c177d7..3d889fa 100644 --- a/apps/docs/components/mdx.tsx +++ b/apps/docs/components/mdx.tsx @@ -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 @@ -64,6 +71,11 @@ export function getMDXComponents(components?: MDXComponents): MDXComponents { CountryPickerDemo, CountryPickerPriority, CountryPickerPlain, + ModelPickerDemo, + ModelPickerPill, + ModelPickerPrices, + ModelPickerRecent, + ModelPickerMinimal, ...components, } } diff --git a/apps/docs/content/docs/components/meta.json b/apps/docs/content/docs/components/meta.json index 3fe65e1..c80db69 100644 --- a/apps/docs/content/docs/components/meta.json +++ b/apps/docs/content/docs/components/meta.json @@ -8,6 +8,7 @@ "---Inputs---", "color-picker", "country-picker", + "model-picker", "---Display---", "code-block" ] diff --git a/apps/docs/content/docs/components/model-picker.mdx b/apps/docs/content/docs/components/model-picker.mdx new file mode 100644 index 0000000..6a4a123 --- /dev/null +++ b/apps/docs/content/docs/components/model-picker.mdx @@ -0,0 +1,280 @@ +--- +title: Model Picker +description: An AI model picker with search, capability filters, recents and locked models. +--- + + + + + + + +```tsx +import { ModelPicker } from "@fern-ui/model-picker" + +const [model, setModel] = useState("claude-sonnet-5") + + +``` + + + + +Choosing a model is a decision, not a preference — the question is never "which +name do I like" but "which of these, for this job, at this price". So the panel +is built to answer that comparison rather than to list names: the price sits +beside every name, locked models stay visible and say why, and the capability +chips come from your own tags. + +Hover, selection, badge and fade treatments are taken from +[Country Picker](/docs/components/country-picker) rather than reinvented — a +block that picks its own selected-state tint is recognisably a different +person's work sitting next to the others. + +There is no bundled model list. One goes stale in weeks, and a wrong price +inside a component library is worse than no price at all. + +## Examples + +### The detail pane is opt-in + +`showDetails` adds a second pane beside the list with the active model's +description, meters and tradeoffs. It is off by default: the list on its own +answers the question most of the time, and a panel that doubles in width to show +prose nobody asked for is heavier than what it replaced. + +Where it is on, it is open from the moment the panel is. Revealing it on hover +was tried first and was wrong twice over — it made the information mouse-only, +and a dwell delay stacked on an enter transition meant the pane took the better +part of half a second to answer, which reads as the component thinking rather +than responding. Nothing that can lag is the only preview that never lags. + +### Meters are relative to your catalogue + +`speed` and `quality` take any scale you like — 1-5, tokens per second, a +percentage — and are normalised across the models you actually offer. "Fast" +has no meaning without a population, and a fixed tier invites every model to be +marked a four. + +Cost is normalised only within the models sharing a price denominator. Credits +per second and credits per image are different quantities; ranking them against +each other would put a cheap video model below an expensive image one for no +reason anyone could act on. + +```tsx +{ price: { amount: 3, symbol: "$", per: "1M" }, speed: 4, quality: 4 } +``` + +A `symbol` drops the unit noun everywhere: `$3/1M` says what "3 dollars per 1M" +says in a third of the width, on every row. `from: true` marks the figure as a +floor rather than the price — presenting a floor as exact is something a user +only discovers on their invoice. + +### Prices + +Off by default. Most of the time the choice is made on capability, and the +figure is one more thing to read past on every row — a column of prices also +quietly reframes the list as a menu, which is a stronger claim than a component +should make on its own. + + + + + + + +```tsx + +``` + + + + +### Compact trigger + +`variant="pill"` is the form for a config row sitting beside aspect, duration +and the rest. It stays 40px: it is the compact variant, but the floor for an +interactive target does not move because a control got smaller. + +It opens on click, not hover. Hover-opening a menu in a dense row fires on +every pass of the cursor, and means nothing at all on touch. + + + + + + + +```tsx + +``` + + + + +### Recents + +The two models someone alternates between are otherwise buried, and re-finding +them is the most repeated action this component has. `recent` pins them into a +leading section — but only while idle, since once someone is searching or +filtering they have said what they want, and pinning the same rows twice makes +a short result set look longer than it is. + +Persisting the list is yours to decide. A block that writes to `localStorage` +on its own is a surprise in someone else's app. + + + + + + + +```tsx + { + setModel(id) + setRecent((r) => [id, ...r.filter((x) => x !== id)].slice(0, 3)) + }} +/> +``` + + + + +### Capability filters + +The chips are derived from the `tags` on your models, never configured +separately — the two cannot drift out of step, and adding a tag cannot silently +fail to offer a filter for it. + +They narrow rather than widen: picking `vision` and then `fast` is someone +building up a requirement, and answering that with a longer list than they +started from reads as the control ignoring them. Chips that would empty the +list are disabled in place rather than removed, because a chip row that reflows +as you click through it puts the next chip under a moving target. Backspace on +an empty query removes the last one. + +### Locked models + +A model gated behind a plan keeps its row, keeps its details, and says why. The +cursor still lands on it so the reason is reachable; only selection is blocked. +A model nobody can see is a model nobody can upgrade for. + +```tsx +{ + disabled: true, + disabledReason: "Available on the Scale plan. Your workspace is on Team.", +} +``` + +### Short lists + +Search and chips appear on their own once the catalogue is long enough to need +them — a search box over four models is noise. + + + + + + + +```tsx + +``` + + + + +### Logos + +`logo` is a URL you supply, drawn straight into the row — no plate, no ring, no +box. A mark on the surface reads as part of the row; the same mark inside a +bordered tile reads as a favicon someone pasted in, and ten of them down a list +are ten little boxes competing with the names beside them. + +The tradeoff is real: a monochrome black mark is invisible on a dark panel, +because nothing here can know what is inside the file. `logoBackground` puts a +filled brand-coloured tile back for a mark that needs one. + +A model with no logo, or one whose URL fails, falls back to the provider's +initial. Not hypothetical — the demo above hit it the day a CDN dropped one of +the marks it was using. + +### Keyboard + +Arrows move, Home and End jump to the ends, PageUp and PageDown move by ten. +Enter selects, Escape closes, Tab closes and moves on. Backspace on an empty +query removes the last filter chip. The trigger opens on Enter, Space or +ArrowDown, and the list opens with the cursor on the current selection, scrolled +to it. + +## Installation + + + +Or copy these files into your project. No runtime dependencies beyond React; +every one of them is imported by the component. + + + + + + + + + + + + + + + + +## Props + + void", description: "Fires with the id and the full model record." }, + { name: "onOpenChange", type: "(open) => void", description: "Fires as the panel opens and closes." }, + { name: "recent", type: "string[]", description: "Ids pinned into a leading section, most recent first." }, + { name: "recentLabel", type: "string", default: '"Recent"', description: "Heading for that section." }, + { name: "variant", type: '"default" | "pill"', default: '"default"', description: "Compact trigger for a config row." }, + { name: "searchable", type: "boolean", default: "over 7 models", description: "Show the search field." }, + { name: "filterable", type: "boolean", default: "2+ tags", description: "Show the capability chips." }, + { name: "showDetails", type: "boolean", default: "false", description: "A second pane with description, meters and tradeoffs." }, + { name: "showLogos", type: "boolean", default: "true", description: "Provider marks, falling back to the provider initial." }, + { name: "showPrice", type: "boolean", default: "false", description: "Price badges on rows and the trigger. Off by default." }, + { name: "placeholder", type: "string", default: '"Select a model"', description: "Trigger text with nothing selected." }, + { name: "searchPlaceholder", type: "string", default: '"Search models"', description: "Placeholder inside the search field." }, + { name: "disabled", type: "boolean", default: "false", description: "Block interaction and dim the control." }, + { name: "label", type: "string", default: '"Model"', description: "Accessible name for the trigger and panel." }, + ]} +/> + +## The `Model` record + + diff --git a/apps/docs/next-env.d.ts b/apps/docs/next-env.d.ts index 9edff1c..c4b7818 100644 --- a/apps/docs/next-env.d.ts +++ b/apps/docs/next-env.d.ts @@ -1,6 +1,6 @@ /// /// -import "./.next/types/routes.d.ts"; +import "./.next/dev/types/routes.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/apps/docs/package.json b/apps/docs/package.json index 30979c2..83bd583 100644 --- a/apps/docs/package.json +++ b/apps/docs/package.json @@ -13,6 +13,7 @@ "@fern-ui/button": "workspace:*", "@fern-ui/code-block": "workspace:*", "@fern-ui/country-picker": "workspace:*", + "@fern-ui/model-picker": "workspace:*", "@gravity-ui/icons": "^2.20.0", "@heroui/react": "^3.2.2", "clsx": "^2.1.1", diff --git a/apps/docs/public/model-logos/chatgpt.svg b/apps/docs/public/model-logos/chatgpt.svg new file mode 100644 index 0000000..2312a77 --- /dev/null +++ b/apps/docs/public/model-logos/chatgpt.svg @@ -0,0 +1,10 @@ + + + + + + + + + + \ No newline at end of file diff --git a/apps/docs/public/model-logos/gemini.svg b/apps/docs/public/model-logos/gemini.svg new file mode 100644 index 0000000..1111255 --- /dev/null +++ b/apps/docs/public/model-logos/gemini.svg @@ -0,0 +1 @@ +Google Gemini \ No newline at end of file From 6a06dac70bd756ffeed05fc465da999af0888ae5 Mon Sep 17 00:00:00 2001 From: CrackedResearcher <119443185+CrackedResearcher@users.noreply.github.com> Date: Thu, 23 Jul 2026 17:20:13 +0530 Subject: [PATCH 3/3] Update the lockfile for the new workspace Co-Authored-By: Claude Opus 4.8 (1M context) --- bun.lockb | Bin 262096 -> 262744 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/bun.lockb b/bun.lockb index 92701d9bd2d628e414801b5d36eb1f12fa0467f7..89a50f8838f71927b547c163209030b62907d56d 100755 GIT binary patch delta 41772 zcmeIbcYIaF_6K_QP7dVIOCSlMBp@X~0tti!LvjF>jx?pWBm@W~v`|D!0tkYLlr0Xu zg9M~Rs#H;sUPUelh#)Ef5h)^wkoW!WnSBnySnhrI{@(k;^T|5jS!>qJteII;_TF={ zPh=H5`$nN@AwfT_zI8O@^{DwyufN6Hd{U-cwgWm zU_syvr8gYt2fiDy1n_H=%J`S;66X+8AsCd|NDX=i` z^4M@7O`G&vp{ZWx;3oqWuF9m<|DP5U>pJ z6<}##Erq@cA0byZ-6dd2;HL`Hfi!G75UOdz6~+Pam(~nlG^Cco0EKx{Rp4jDvT46o zcmzm`w<=tw&;ZgG9D&k_2B#z?#-T@QxE8$GB`dZ#n$FI=?jg4RO}bquYY0>n=KPv#DXpu=^kZL4^@)cqJI+l zAvP{H5&dqn9jYi(-3XGo_Uw^3i1N)9WIJuEEX~>pJ$l>*rT=9WIRxf|M@`Z$R+a7i zRoGlUtJc+RH$*YX z+&~r>qxe!l=KB-$(M4(h0@Bm6YD$B?F)`Jab_4+`Y*IK2NDt|e5I3Y3YMh+HdSlQc z0~#_WLKeIQhB4i)NVqj{K9B{b0hzuNuskpd7yv8^L}RC=BONOiPzQRPJ@O&o4ZH

fwf+g9+U>;e0T=PF7pL)%=LgC z`OKGOK5dnOQ9v_aAY1Z81;tqP4cXC9T3n-%v~)o1pp=Agn=PT=;Kad+2`K@P zGvmBKj_U~u2-DoZi>i4p=xCf90jfEa-5Z|w7Y|3B^Qt)h<_@tEA=qqi(vx_dbl)kaM zmGq5;K>ETy$T|BoNo^wqRe)t6CSn#ar03chk zpfaQ)kWF2woviT#=rdhtdud29Aj?Vp6$(_m3gqxPrtrm%vPM-E-t8cpusC??4eul~ z{sPE?_X6od8sgcspCTW&ntF>eG-r@63|=bV=jHci7CCnvtVx^eWh-q%-;c? z6?)VQhNmKc$$Iec0SUHY$cP0F>>~?Ugo0V4w}CWh1dvVqFBnGL4=e=qO_IJc63F!V zlI7Ys9mt93j*5@(C!78PcpBURd;)8}6&Z0HF99;b^42ELIH%==mQz~JYB}XDbPWy5 zut~!Qrwkc@pe@u0%;S^Vd9bX;fW$$ugA-D0$*(icW(yf2+hcI=gno%Vhug|2zA5yX zTYVt=r3#Q8dugb2n9qQ8n7PBGykd%K|NcoyEVuCpIkc()OVHDbAi(yxkAmrf7l4RJ z+YO|Asgl11WX4HAc5riG8DKTV7XSu=|8cl1;3$y%8pTfqGF=joD^(Lq8XRDioq<(S| zi?-Q@^z2WtwV41tV^d!LR3C(xLqba2ki?`Ow#$=b6C47vZ8rkx0SgpPR5(asXN3)c zbj>h@rGehy^C-MOQSw(59+TKHZJQFTP&h+jDv(u5QrJOZ1BEq#^zE_=3n_f^rj-9+ zr01)cdI=3s09u1)$e}j`4$11KK+cge%#hlZXUb_gH;_Ga^<9ad0Ly}33}la{DeMjm z0AE*OAdns90b~bOnr${mYTAQYvOsSLyb<9EWWnE}U@9H}($#tb>Dw`LWjnS7Prbph z17hQ_)xQEc=ZG*ME08f?_S0;jFZj_wdPXd;6tEsJfG$=Z0lHE?pdawoJgIOR$OPMf zC4ftT?C?HVSLq@RVF0IqLrZ1NR{>e$$w1aLN#V;tR;0SZqCgt(%Mz5EiogW~SkNAY z?*m!j6orF1JA>z3WbHTO2F1o>lCXW0F6CK3>bF`YeLgWIxK~pDINKrc zOqV<;ku7DjVN94cGfot!l=^?{-_N>tYTAFzzm=Z{YSJNI@85%!wLRJ(J-zZ~>A7AC zuWe%gb;3>qfpTb{WxzmSDzH4TJ+LCMhT;nXp97z{Q4WoLK=Q+f^c@n6JYpEs(84~cN6?zA131kN$2hSne_Ve zK#qnk`_)nfp3Yto$U!jZfE-13@MXdOhV%~Lhsck^dMS{juI?c-eQFvSdQf7&UbZ=i zpkndE(vWUIPJcUroS!GFgvm#w!F|DVukjkND6k`t^GgFD=b`R!8&+UCko=FwI!7R6`@CihsP!*C1OWx zUA>M&#+*Mcot7=|DX=8?dXTf`+8J4a?%)e^{%DCnA)pVC8RrGkfRum9Chd~~6X5t^ zkTZjUKpOfSkn=;YgnlW*`^DS(r1b9>4!sYK%Pu*9npQyk1|YlmT_Dq4|58pDr-9LI z+Fb|`--H4U7=tD&1ndK3Q>9;!2DJcR7<`m!(kUCp6kX`>dbH$J=%G8ck8Y?`YOAML zm#BcDWyj_^v*GJNFT3`wky*h>U$8mEo~v4f9UzEii^qKxE0YX{VgLs$mw_mc{f1%T*kmk z5n8$tRN1NBG7>90^}t-ls>(6;KDkhUne;P+USP=K$ZfMVK_Z87yJCcP(MYf2bQJco z*_uJ-X=GH1u)hPYqp_w*l;avg?IG8Vft4d1bYKTddmil>XL1=$A{_I;H3DZhb9A87nZH>9M=Us|16M853(U)=B9q+) zR|lMjkr5K%2u7E61*e)^4X17^V)UsIV_%Db+ulg75oOQiZH2lb)Yg=3MCcVWT@t%;Ey?$quYnc+@*M=ZJV#?A03`%YIV05esrDI0@OH#79LE9AudC1nc`>SD$| zLa37&YKt)}Lm3FQH`X9$2lfH4n4vDNPzFM+O_>MgNi-1j+PFeXU7@?K&N*`pC7Ue*s(CrA9B05u zkGNem!jUJ)W~+-hPt)U?fMc8oN3CNtxa#0A#NdBxjr4j>$34iNH}yG0LyVyMPDj_u zHd|*iRmQ+bFEAW8m^i9OI4W1c*anx^7+5F5kqnMD!+9}Ou7hiA#$g!QYg9G8s!o*S zAwu0DN3kD9dIj5Ty}_WG80OD~p#5bO>>B{?Ef@DIxIQMw%zIRm;|fJ#jBW(?GU8+{ zbyVs_aHu8nYyhqvIJC|ykzQcrtir)lF4Si0=gM+AI9YWz&LB)GthAhej)8l{rCGLy z6-QG>gL4~p4P0B7W(-=pqsg&6|8T43OgADMcOINOU1+VRxi`Ua9+QJS(@1aZ)cqrj z>y2ZyhDJ~mr{gVjKgyNHYCDZ|$gW1(Y=bSCS9?q71Im+{`f2TR0s@A)|+R7#tlH;TSXzeuM5=r#PAVE;v}P@~T!}E?qKiBDe;w zxMSdGsqCJrMtUo!qbCYh9sfe47Z~Yv89@=4q!U{^wW&sWYo{XF49;N66Ls!5Y6#22euQVnya3dx#XsUqe*s? zy9thEIZVxZO=QK*QL4>065BZ)J(|i{5WUhk!m%D4Q=1d0{T{eRMsR49R>w$e?{o-s z6&w(;@Cfask=fpYjB*Ez+on;hMVj80-sZFV7T zc1H)B?NuYWOO&H4LTFZ+foV7$Tobmiqchx7=FAGO%EmnaC)=GRgc(6`=7NXs?~U|0 zr@bO-j=f@Bl-9!tig#*jj6{6jFw*0lj&WUN!=TT?A{>{%$%a8r_HeXmQ{!g0C@sxM z@8Q(GFfw~M9TmD+H4a^ey=a2d(HlJx1DidK(9j6`3UGCd2!37vt${SBhdGWy`0)NMmoOx8=3gtYy|aoYS5}l4Z2{s!h5jj6-M~(DEr&h(tOmy0}^rUZM8>^SP96S%rYaL*`5<3wwP;i5GT94m=|6C9!KF%pxUjx5M( znZ?s5!!cuXFku$x7~z-%4o=FB#Qxw*a4Z8=5021F_BB=|$2hw7m06lXZJrU-&*?Z1 z84ZN2FZ(jRpHq9s$iz2R_x?_-`-%OXj@4TlxFGl8Ir@i(Nw3CtCJIXN*A=Tj6JAFjCSt94ZU%)jn?W#A_ z>NwOz8(}04rN5-(yP1(W)am$em~1JmRd9mejKpD1tjy`doc6>K9G7c`McEG_^r{&u zGm@KI%3epPgBki9AsJg~lodM;AsKrCp*E&owb9%xGws_5bu&XZT(U-~+=erDHA1r7 zd})@fKSEM=0wI}8@EDt|mzm;Sgc8iqLxg0`?Z;Z7EeOdJ-s3E*k`a=6M_jVWF8^P5DXRhD&d*HAM$I8-lylh!>p>vD}R})(BRrt(D;JTWChZ~t`POY~QG{#&k#yIVdCUFNE zJfV)~TP!_!Oq6{-Lb#YgDAN@RpKQs-xI#x=A-}ij6TxrR@iYTWx&|Rk3<&+~3Pn$` zWaC|-qpnb~cdXbhuF!H<=q5r9jNtKgJf||ijE9?{;IU7M?9l18H$sh|h1EA9LeD+NST!Za(Rj|& zNf&{8)s@lD;G!+g-e#`ZJ$<6EvB`YL>9`4=4Qx8RUT&VzXKIWVXQWSc+7Hh+N6*wK zd!+^D=s{=t=Qk0; z$b;UG2vs+34vun^ULxl+%(E#GjvGs@%LsDKmsuAhgEHZL?ff@3ndJ$@G)d(j*i z_S4|%n488ztBvckVvI^Dkw(DmiH^pr<+XDzBcpMIw#>+!?bOa2L35n;q91T351xbm zLa2oq+JaDXGvv9(3bk^D7Pvw;T%pLd=BkQZ#<@br5Nd4Z;38ORC+6~ix@i= zq26Zbx+x1@R>$)rUdJ+WJwiRrQ1NX_roCn)E^}%bM*1?R{Vt@q5xFeNQER*OXt^5A z02j%1UOQx@FL&Ct9h@`QERV9kgb?oiAT${vT$!ZT@pMJ{?BsgF$i4_QGD8~>!mT98 zvJjFGEGu|9ybs7ZN!|~b0j{@|-tjBAByjQ`ZO?;pK-)PFcx?k$mpK(4 zC+~cC{R&BgY?4-oo+;n40uoLGm}lxnI4*$W6oG4+(UG|i%PGi}p<^H<%@A!~M0 zj#Hbg%%b&<+((`+FJQXE3xd|rFwM1}23Ow*t`y}}{HVMnlPLqLI&wqMgjrd(JJv$d z(kzPI@B|#KM8T~i95s)jkHL8ww+BbqpFfVfI!1C*l>IY=BF)gJC(MBaS<*>!5F&I4 zA?z}eqVz(ij6R!U95YWz`_a?52Dt-{E@9psIZwoP2RtJ}A-?42Cqy(!MZ9C_=puf=_I#&Do?LigxE7K$I{oFHTopQ;950vo6{b4 z4#|w<_EGj82(>jc-G)#AOax z=C;{vBz}k`K!G4HP!LE5RRL+Bsvv%zM#_Uh#1IhmtAjj1H5AqYGCl&t50N~qP9@KP z%?^U8X2QC_yr8Baeu&JtIT?KZ3>}DX3!;8|g&lz`uoH-ebp}9qog#?Ja&Oq~a8%$RD?`-4?2NVo~rP0y*Ng0~y5=32c|WKz8UM zU;*G+AoDw~@GBrcM9MGg7`l{wqauiGfvZ4TnyL8fz#`y(Q*sTd5wv*#DK7?OO9cXH za0n28worW0W9pGn$l!~Le=!vSX4F6lh-|W#fi$Fr!j?eRu$|)DE9{`KBQP)G6M)RG z7m!`n7swVFsQ4j3w#+ynE0{VF0Va40NX2PD{MihCJ%cppT_qiz3n26R zm%?*E{Mjzxiw0dbd1JwW;&fx%b+9z!Hjry}F6Q+NGCseOKaEIYD+D>sDy*=G(lgO0 zb+C*%6BSp|r;!VLsFME=QSQM0hXtS-Iah?OUCUFJkASIXBHOJtc#5KxoJe_H#pi@< z=X!|e9=4Ox`%6syyNdrKz0|+7Fo%L!)6Po%G}2?cDmjsQ-GKDaoU~wv z|0SCK&jQ(W>B;~in{buF)e1jQ@kGY2QT)@$rriiRal67#fLxDG1KCyo0&HQ1L_tALEM!JyDniq@Kq1SIBrBJkdkR zJ#wM`tXXadm{C3zL1fMNAwC`EFbV|G7lJ@6s0xS~R|WC&G*TW6B8Gsd@483D_Ggp* z$xSN6{_pNlz53TTsiftxFoHFRqc!*tX?Ow|e4fUC?opvZ*jP&xE(P*KqE{N%biz1Wom8drfa7oN2d@XJhE{n!*0j`L}gm1({!d1~~ zGT>X0PWVn}Zv(y;9SGM%1|d^8rl1;Ir=S|iQ&5c?;v)#OA4K7I05?S<;YYEDa7*}2 z1^gri5^jrwggYW|8sM%NPPivd5`Gpz(*gIz7{V{&96(H*fkL8Zppb`R(hLY94G3;h z@JQ4)Ah<@sd;@~VA`=4biKzE3AWO^!=rc~@(zX`=B=NOiH8;2|CoJVmSb0Ctg1 z$R#uZ$Spb$yhH}UAsn*+c|7!A)A^`}pO`eCHC@1(Qs6IYFJMh6n7;smQX-Rr zSqmX(z7T>kV)jA^8ZCn0F$IC5@gfKwP_SkZ1m(m-3RW(Lpvz(i%8T^H5VT(cLH;EW zR1_VSK;X3$f}Iou3CB_hwo;J16oM+^BMN#igP_zh2!ci8G6;N^LvWOWYQk?h1P3S> zwH$&_agc)7RzMKE0)iT1_zDOry$``f3c^Lu`w*O^V9NUtM2K?~Ok4>;^hyY7i%BaX zh)jpzCIwNVb~*&tD43rPLA1!EVAd)Kny-SuDQ2&NpwVgw9#c?HG+qtC0}9rxhTsM9 zkb;#TK+xp_2woEDA3)H44FvhuK+sThSObCAS_pPh&{#OuLa>#BG-sXeAC(@LC20!5I*=5yLYesPrKO7b$pE1bqm> zSqi3n2thk>j)I9BAc)=oK?gBu0|b#9A-G9FCsBJN1lK5-zY&5iB9nqyn;>Yu34(57 z_9h4#ZHC}61+k*>X82y*7W@voou~aSo^0mdQg6TZy^bGcdy0}*H7`F~S@S?j8aH&- zf9I?RSv~>9rKaBcU7DButLJbogt97>ZTVrB*3wIh-x9rFYiQTKdvBR=L>sMZpS(qg|mgCMV4x^;~wnaOmZP&H0w(LBsMQHi0 z|DUvTB@W54u)8eGF*I?oc~3h=-2C$C-13^a>4Dmo$``eXsLFyrp$Dzoi~23iJe!q7dZKStHtY;&TWa=uf=Ji^7mSOdmt3d z2JqhkepNeuZ!VcvTf_rS_%!M!dUVq3@`=HdEcv0hBx0F0p8)Ygo;T*1h^LbHDWh~K z=b=P?0+ftb*>~~9JOY)BOWi#sD+?JPdGN`|UdYT7BXyLJ&qdaO_-P9n{>rII~ zqs2n`{47JsIw?IqTic*yT_B?&JS>DCQFeh zXseR-R*BgvgOsd~lJV%1U}^XyLB{;}{C$j)B`X;pSr<~Pem^BE$|q(=StWe>E1@^S zJY&TVPn|JCp4O?LWCJ0?AO9(iEl9~ylpYp$o0pPVcmI4L%co>Kt;V!`lv$86_Wx^2 z$bPxYSbSawvS1$ES*BzofUHzWP-`V)|FKf^gZ)f^&l`|2$jbtrM55n}SF$n)^Qav^ zJR(<%iUFX*O8BNy420~slHm-Unc-{Qq+nj6Xx&whiKY|rRKO0XSkJP^U% zbpKSgg$CleH$L%mfcQv|4^}sVHi0&Swt%*Rc%^j*^fTx_=oiof(66A!peLX#5ZhA& z=^zgf&pR9dT|+rMYZnHp;b9Z|yXk)Ap9jPLO>`LPj)9JYPJ&K>J_q5a1hy}Py}SN` zV;6$EL7$3N-Srxd;RwDC>LaFf*Zp%7|JVv3gwL4hVMyMSwy<#l=UldbiYS2v!I2#93~T7svt11Ih>D zS&p5k>n_l4(8r)Xpie-bg7$*;fj$H62OR_*0{sel1o{oc$GcIW7?2ZG7gP^a-wrQ- z0RjG-@CKlUphlp^peCTEpcbH(pt>-GCvZ1{Hi0&SenivU0^J7P0o?_426X|o1MzqU zM|NRQ5l~T(H>en>ILOB?^j>Q9$%Og+$R0$LW`V_>u>i}pgs=Nrq zW75SzQ$f=}{6}pwK!%7-(0x+-A=n?(0%f%VwFYroS=fFObPDu2XeRx65d!T&9Y7sHoj^R3&vW#l zpfFHPPz0zpi2oJs2I%qD4r-}VQ+z%As ziQ)((!{hk>{PcL41L?E~?w_ioSz&?eAt zprfD@pi`jFLD;<55D z-ojXb?J=l37_PN+S#GA@Mnq>|EfBrpH|RW3`bV`b1rqfIMZ~lb`g7Pi!|}yOBlL!P zrY4?@(0exaN1_rSUl5VeX zH9Kdy2jVh<1vu$p!8J8~bWwVg-m?RpbQxqzL1<;O3W?CG33(;(AA)Ki%%2miMK}yt z4O9hG3B)ql6k1eLTpp$S_?1Q!v*Ywv9#jrQH!ULykJkMI0}y-;+FVUJ zRJgsMHMEkY2a4vS^@{nQL)3V%6+i)E%xJw#&>y8_s_g0qkwYm?yR0IoUYbaID60yp zB=V|#$5m+0#y?=paeTLm{lm_J&ILnchbG0H?V)CqY#5c%dH z##?bTnqlT;)!2&b2pNZOdk|$Nu}PUJ#}3CM$IL>6`FkNY+c6Mjyf884V&Dk9OrA#O zFvU;{5WlAB=AK`a8KWn*$bc>`1JbO2Xc>@eo|0IvRatR--lGmode)^P9%q zAU42h3(Kcftuj6#&P(eW9ug6P)h1Z%9HaaBb6LRkM%sW5ZT6PDw(Px1TGyH(HK@=+ z+=hbIUF01LJ&slEqSK~V>o}#trs&$x3#%0p8WL(7BAigDH3kZ}5=xu6eZ?u?VJE9X zA+&Z#b>zgAlSSltFMQ(2;2zCY5m@a4HSx|^-B(*K=08GF>%|`AgnNxA!HIig_1^x^ z*14>!&bvgHai;RHae58hL|jcSQ|ue3H^+UtqHjQ0O_X^P!iFOD4M1x#0^rYCtpu#u zS#D4D#&5haUe{RPP_)xzr<_q zm~}#%fFiONSWS$Y455n4u9A)O|JqY#TrTl)uKW-4)Y4@q;1HkE3m5(qP@$JZ3ltMR z2h$qu_+j*kbzM&W`Ucd)PzM|W{NtGHrY#jGY41jHgOJS_tGSfWY`o&0$h(?YycRaJ z5#2J7CQ*#p02n21v5BXOzH!SA*rSo0hxF2^FoFSG0)Qgw**aA@wWbmjz#S6< zQ5brFoL(xgXtofJC0PruiqsFFb6a#{3Kge>*)=DN%T!q^9uc;RJ@edfP=qdoVR4gT znCfD;@gqgXWH?}$$U@h*u-*uxulVUu&)!?B=vp|247$~3y)));+xL#1Iz6_Vu2l~~ z9Cm-!TV-m@h<|<0{6(X5tyK~8;x(<0==C-#JXXwo8%=4whi5_i=g;V$#+UZgLTiPD zg@jhOeISlQA>4X#&$ORb9%(T2NIAEH^=hAUkAC?q&&_YfyJPN(Qd4xllGeL`8cnb@ zsrr6v%EsAxS!5A&~ERF3vt%Qx(6v}R)?Z;bYka83s;#X@8e8V3ceEon73FMsdr zGKB_tYn4%Le(qs9;M6q2H|p2KH(%*lNE-#+P=R}B#o$j_Q*bYAln8tWEk04)zOQ?W z7VqeGf9owhU+*dQ`zYSauti!BrVp&V|#)@RIdy<(ej`Y5-8_2QqV!?Y%? z?p`S5j`>>bMABN;n}P>Qg4--OC=N~6n`u3T&kVha*T8aEs!$iP>#^?RiJ{Y2jL|&2bD5Jy8<9E#EgV~3 zE{uJf-zwPRY~XUtDxsKF!f-{27~JVfn;H=p)%a9!s2PJnQp+|~97R%p_0p5HLJ!xC z%>P~Ea;5^@xVG(Mp&4j`bHX1`@&Ph}E2L%IJwI;Uch~y4^$J!HEe+i_+SL3~^>4(e^T&I6?_XucnA3GzJPtw`eZ&^S9N0Gq?}#$*LO5H*?t_8W zi+JJ(r4H}dzw|C7j*v6vW-;|WsJ|^1Q~iY44)E`e!Nv7num5n}(HHK#?H+bRgtiUS zme)+Zg#Q&-#3A@vpE+-&oGrK7t#waC&qSsd#K4)zw3?U<@VDL)G`Dkn`tgq{QLwGRJeGM+DiNnCPp`LJK*Oip_qi&&Zc*W!-P*sOI70Xc2Vrs6>&0#y2R)6b#SK&9_Tk`teD+A2# zG6&Qaagz$x8@_(J)2{8TCDXr!LMTU6b=#MCN{H@K+0GaJpW@^a>TkU!?EN7BE*W2R zDD2LCrno&9)~ytIv7Gu_uNgbM(7v@>fp_*oFHCy&Md9RnYcBA9V$eJ+v~ofASMMlG zJ5=_<{ds%eE$vRD*66oH>;mL%z0)kXO%jy{|u>yE&h`Za)c)+oZ`N^aGd|CKkP~`|72_M9W2R>_0X|?s6C(QQNTgV(L;9lg(riuu?Cf=ayw=kIK%ubX1$Gj!>Lx#93nC5|pq>OeUym z9TB_1`G11}fD75QkNQ+Dm}VP$*==67djE-gWr=#Q|8LYcr;1wEn(pzCT8t>XZv7Iz^b**i7Dw=XROx(=E>D_ zO^eLmK6WcuFA%IYapj(Q%c6xlCZLYEkd6UwPMus$POibwRc0 zSs`)>$%QEzH`V1tC9`r>SI$-9%4)2i)>{jss}9?@^zrsnFiu^BDW{go{?=O!-&y?O ztB*$=e-(OI^jSu>3n_80u!l7n{?*Fis?7#5mzR%6#ARMgekG2RQ#J=y5_JoE_-0$h ze&0$Z$JdpYMKN9V9j9-3UD91w7&&ENwwief+dO-V`uo+B*ZY&M1(bNP^S#eeO|G-3 zro4c4&Bm@v%*pF8*#7uZwy)R?1vy>&`A1wf9s8qYd@|Z5)m+{B}!AB zude8G?3wHr_O(Q*jVMB0hs$R1PkT|`lyEE>+AnL~*q?BY0<9Q|+4_vg;OziuSPs>Y zmf_w_b2!0&-$2oFBPKJ|EDDy3g{*mBH)0&R1*+BLU5Jv_>n+bjw=CHtu2m#j zEmHbOKqE0=6UKA48zExHCKQ?dK1B_2X$$t>F`M<8PZgu;p1pnDswJLMl@^Jw^Ldo; zw_dOL!RIZ#PItcE3ij|`8+>-Ln7&1?;cvZ%)Az=Q1zHSf(FqD_?8?lo4d}KPq{n}If5_6MNUerMc0*;t(kRw?UFY7p)%QIZ>s1~pxX;Hkq9@{;t$K}c z>qVatJ9BT@Ub4l0DBuEtmk8D?L8B+MIFK=`S$i|aym&D8*i{@MctnK@hO{Lj^&|9= zlxiX3(nq*ckez!+wAhAzeJ0@(F@KvYpZ(@cBFeC zlDF$s$`yE7UOPOi@2y!eJ;e6yD7~Nf;@?WvPqf>iSJ9(dh&*R?f3a|fZr28gj3dZu zthl@jFjMS4h#d7^JBK|dU~}$P=5K^^ zsG}9LP+Vl{-6Df|sfM#6pJ8)W%E{DcO#E3iKT7-Ch`J|We-$x`g*6fN4!Mi|5AA=( z#2nc?R(&i3WP4f-SYM3TXVu+mx#z4T&-Qj9&5E%yd#1>oa{C+o@JuexusOH<(^AXu zXv@4mHr#pxt$(3558n82=|s-dyyE8L`@eKIS0`rgJ=0#xhQCqWPes&mw0U;lk+(cF z*@FJodv8Bzxbekn74sgTndXFn?Z-NH*fXO-4u_J~>vP}uu28_t+=X>nyHHe5b(mhP zy=e9s+%Qxehgbfm14p%$EcMw|QKRbLZIx$wJ?Fgt-AvUqk(0ChjgGo226F1rI*PXs z;wsX5*|6XE_AivOcRg*+^X9@Mdn4R>JMoTRmOmW+WrsCx=d#{k{M**)>1WFK#Z`>u zb~)GcKQ#F1WK|s2Xpq%-c77W!h9AZRe^0DAjGc$|!sPT8rB2S9aSBH|u|#2+=lQA2 zP{7f46I6p{>xYt7K{*XOG3|&R@QklN&-m$I4`v+J|Np^|%|6>a(*w^q;s3jK&8egu*Tj-jabua!NphY0YTo1?#fGZs z3i};vefbZ&R`n4d!s4pcX5QHK^Iy_UKF^rFHLd008oTeQ;_&!$tI$rvyY>rzfWP%d z>kWO&UEI*B>0n*M)LWB}Nw0|(P^e|Si@R&5kId;bAF=Waf3^d$N<}6ZEoTL$r#SI$K4tj;5m)o;M z!+u}SIV;QHLKQ5^e1To=?+05P|UMz+Jo;;g+jZqSx`L0b61s+d1 z73i(SM9a&f<>D6f!hdhFve?!W4=8xE`9Qypvs)ak_&O?sny|VriRd$!I&;T~?q{*w zToVJyddG>k&*+uQRf)sV6Quin9jus_#KnKm}+pN7T8p zzw({3W}DXH2E47feD?R*$jrGJ#-=ks`&3jpi_HJb4>B|E(4qAr7BIufLrjJ+RAz?<01{$7Pvq5la7BB(lz7Tl^RHp;tDE=3lzo zqj*mda!IfJm-WhjRxkD6aFt=A)p=J}``3hX^4!O|C5R!~tO8!0}25zm8S|Qbyrq)Zme^IYdv~Hq&QgvHNJhA^E_~CLTIjc#9ZNMvfXz+~y2Y+>X1;zDc6i<-au)RiBI$^DkqE zbwQlGj2hh$HvoFRWRdHN9;R4rx@dC+IxZ**-(Ab-3ez6B4_>!8}o=jd|=pZU2tw9;Y*UpWz`4^B$_r++*Q6 zBy`PG2FRM`lpDbYkHVoF-0u8hA02#BVV6V zp|(&Y;bZE`bJk8<_IlC?vxUNVrTV-G%!CQ8MN}qAI4=5=t0JnE@F-py(e($*gl7uW z?pLH()5b`s?q4+(J2T-*vWU{5w$9M>MiJLr#gAw?xe|W;h1EMWq&99#4H0#(V`1&p zTi#}FJ?YHa((Rk%QF+1c5Rr0Suaalj5cy13CD1+-d#=M89L)gu&mAUvxqIXIyk#f7 z%3j8Hh`oGC6ubew@gnF3>M&At0p~C9l$uYsHf8;ACr^n(vi+kM)YeXy*@blmifRaB#F93QG71uUD)?VdY)!(z55~+3gJaY$fsfx2hFW`abn~A zZiQ-yVbc52Xrm;B6BI=9pZ|Y-op9$h+&7-(T`%&+M^KUnP)W@KElW(E) zAKsKV1;6@g_x#miJ8ScVPFRGz@3|NXdZCG8$1S)~*@;qd&$rW87WHZPi(9d#xW$|r zK%pp%sa}6hw=KB}qDQTqI*8a@IPqfUnj}JVdw2(!;Z&8vt8h$}t=hXs+$m;=fj`0g z6{2t!j!!N83CE|lL79&JiS5UR6*6`6tdTVInAi>FT33`pT_tVOZT=pu?i&?gZiF&tT3C>Q?lK!`GYqPVMZnvs4 zNK8d^_(Y^H4&zogNbEAmS#h2_{Q?z})O|_Tndu+ZbH{8H7m?Ke5EQs-om^a~)ZR*& zE!+y1Md3RbScAk%cl2Ir7Q#ez^Ji(#9Y9<0h|p6kEQzBi!$h^aXov%b^q25D;l3|i zo3@l*A@4hVix`fJW0_y98NcM`Def4Lcg5(tXo$dfrB~MpDBrcgwI$(hjk+QOnd3C= zVSs-c^a7F77q8Ag)VulhgKoXWB8x_R0tJrI=R#YBywGvmRJX!a#Nd)6Ewh=u6 z$L^TiGsVmIaG_CI^ty+NKM-^8VURjd4iAeaE?rV!Ymp)^VFJQ^7yQ{gXNwciujM^k z_TH4Nt5v^s#)g?1=A2UrG2D3;J9V|{ug8aPl4*GaMW!ttS;O{VmYn{+%9?($SNY?W z3o-u)&g;*X?2hrCEs}oL(^PMi44W&n7`=5!%7Mr)U;ys)nHQ6CH&=q2<%cYV^;uTH!5GDN`#xRUL>L|zi#NZvcQ{r9a7qouiGp>7jJ zmj}q9Clq*v>U-@;NYkl9K7)cf2R2wthk`amtft=k&@-pQ0(VzlymAT_h4El17W_Tp z5)^7(fC6tgytj7d(V;g^zTsB*1u^E@^X;|v-#l6U#2r&~sR((9>Q-MW2Y|o-j&X}C z4c_Y3cv-|ggb_(%1i*hX^f;^0H{)D7{N>K~=c%17V* zIO9;E@ZM(K=%KbO5&A3CTZyKGLn7f=`W8Vc2}gy@hz= zH~oj-otpR7hOd@Q7$zn^MwNtE{um7*_ehqT{~+=|LEIC86~sFby%%OHggLkfTJ-z( z2H#oiyuaO?=FFI~6Gi7I=$o2KqgJycn-|a6__|x8ftbqd)G}fwlFLXgGn^_eGTK}- zyrsxoIQ+D2qDmIJ=a`!R&yVZbAoRU2_qcPuBDz7LR*nmVnRgz{&fYNFi29*ipDA-D zx|5ckB-UnOvN9J4Rjj#6_^AbAWfG&56v8f$=+NYRJo^TW(n9E~| z^0cD=W^H1Y6gS*~#^FZ4XpBQ{?sZ@bfRY$^6w)+L|>D`n77E6wlVeuRGw)HG~ri;q9S-vy*ADtG*a> zdgXeoI+j8zVsH;L?a+@4EC2R<;u&|$GBJgD?}GvttjG6WJdkxf&osBfHL(W@y4PlL z!P5h`1vkqz@20=ed+hStzHUX+aMy`_c`gwD#HEWJHBf#V$?3w}TDbN-yX_u!%fHO% z2I3M@gy-l~rb3RJBr`^i#Q-y$<9IM*%pt~O>mzcZ;yG?jOuZ{&9TaNi=)I;wjw8y9 z!G7{dF7t2}nj6LDIOa?}>GRK<;T-p)W=xKoIWs0z97O?IP4Rth z`1&87=jbjHy%66(%m@6*d5(_ah8MEyD;^gG3=?G>=$zN~%5CtBe&XE2Z+{EK&{2o3 zCL@L$nf{+#I&!Lz?D^xPM1w?Deq@>LXdeFE zQx4XtGgUIjzmwqE>ab<7$N!tl+&~O+>2R$39++~M|0)ZCF>rN4LPINolxU-Q`HgcUj49jrw(o#-Tk+|s7IL9 z$FlK6_o6uTBR!#H@)5bkFYa@6+}6*VoO3%NN@ChS8Nh1fs?L)c`j0#&Yr64a=`W|H zE!c_-BSOOX81+q&>WvZeE)-a|35{c#F1iAn%Fy{5^bipA+Ip z3E*=l#1F+VYlev`en?SI)bmC2wLBqz#B`(2hzXA8Yb}(P)W+3&chQY{14VLifV$oo zCl-@iBDNRDvR(0{_^G&uZ+P{S^5Uictm6eoPi)4co;5>o54yUo4q{l*&q@}3GC#AR zuRHsfMYIpf&*7(Dr=Yrwau3F(`DnSRN{iKM+qge z+EcaTUp8R4c1HGS)Q}baBRrS<>@Mo}H}Vdi0**x84KqK->HhF>OWA3cnf)#bu~He>6qT1Lt2I zv8>(d0Kc=~`O}v%KCc$Y@~-*SRq%zt@2lOdjdSSp{~EymMS{=ES>pLJ9-ovO5F6h& zwpT(*wdDRi5|V-kB*ynm7*w}s!k~V^LlUKM%g<#zs$I-8ElZ}{nk7OWdz7`C*@za8 zJpzkbKb4mMwg~^*!R9HdmG&qhW#Ae#y~Zvo8WK(H zT@z!hiHS98ii#2az0c0DFZ5qJmF9T0O9LxpS#oqgyIdI{$QO&{9i{w@wm$qGz@BvUcdG=!_)w zh#l0Af@?rIB+1sje@p`L332hg+#q-W@m;m=_o)^df;C-~)k0 zfP0*IyxGKY0HGBTgJOHe4nqMsA;<^5EE41fPRS-o1%Qdb{J`NK#}AH)lcW{dxxBFp zFCYL&226##Fz{obD==2*4eb%%Cl>jyMy)8{Ux7t|(LgH|_!X){8SDp=LL?#z0_y_t z-&P)9X5e&076K*;i~>@+WoQ;EuzT##KK%zsQuo+_L!$a152SO0JUftDs--Z(r>UNoscC-h2o&ts2H$P3sjrz zq=NeZsYTiW$-soTs6nt#S_kFg;3vAH{k;+B2?1dPpck+_uoy6xz@L#D@#lfmbi09` zzy$)WKr*Z+5J_y20)v70Z!3;3G9;J4=U|AxWfg&wKx*1O0#^db;^_j13ycSnFWiK+ zRO7Mg(5KvPO=$|k+ zCLuv8SCadJ7myVDMfK|+-(8Xpql>7Z9ohybFYEHMJX`cnY(KB;FpSd836>-@ ziY)<11-1cFdOu((V1A%C@IKO^v29kQql!Hbg&xfwcMxy|9u4CWUxFvs+yp>6#*AcVMnM+=JQx$%Quqsmp$@&Bs`E=n;RoF3+c;Ffcz* z&lgB7xwbTB53+0w+K&vGU7uU5HsBTxh#Hg-8;BbA8yr74J~rVw;wj_XKpJZU8*;s# zKuZ4>NCx&tex#Syh#NYrG1t2fJ|Em{3Aif>+s0K0pdPF4*#vlbx1;_MB7zUS-DuSmj+S`o##@y!IH--ScAzuVJ zb+Na#B^Ss*F9`0o;1MT*G#$B%jK&}VbwO_+89EQ1MSMH(RMUDuYRS8hlOgFqYU(#_ zc#Y41r*zrdaYG)q<>gq^PW5(ed((dpAa6$3w{7O`AXqme zc=Dx?e!S^-fG2~ifRCk`Pe(>Hj)ws$!thq_cbwC3Lc=KyXEmI14!VX68!z!L7@RO< z0D@9>%_oPO)gR-8sxcsbP}Jbq1gYQkqf*&jRSRkdV1uOyd1{NnzlM$ed?qH4}JOU)e z^*}OcvXJ)`d<&o%@*WAXLoo#=JVOn0fxiKyLGO-+rk3f|KQV@8t1loY{l0zrpd?8e z(xX3tRBi(FwB0$)*2hReIm9MJ4~g&7UD`XDH^Fiswe1ujdB9+ST?94|=r6D+kj8?G zz~3hEzP%;zl)!xgS99!On<)e%1;z<%4Wug75?Do`r@$P*0*HS(k>ig7FA4ly+mNe_ zbvGJ-=7I*Z`Os?&hotH@gq%i7Q;nyV=JILz`W)Uv`vuMi(#ZK3=mTscuo}=Cd|`nv zkPmg>RUmcXoB4WkSZ!wzpaLHNU4h>Mso(=baVd~oFbqh(?YfA!V@2?!H#llQR5Z5w z`yi(=-~y!lCNAOq)F0>$z7>#qItW-4=mzvA7yDx|ccnW(4@4XVlEOwHC71~;0!#!_ zhxfv|N-k0q2GA6+d=;;`4M;VP0#Z$D2`mYu8fO!DeIV@febwlRemQ;{*17*mxoH5oI7m||=+ zBUovwFs2$~?lPtlW6mNDqz?-?n5Qd^9mIJQVVATlA*0H@PcC#Vxsy)b&suS zg@7`u3Z#P5kpWqp7}cjwJod}RwQYOIX?EFtiMPN4peJ}Y$f*KfU*;942A&Kq4a^68 ze3s{X8%PE;{F=9DuLPKY(P4EF8Po@orLUk!vq#U^ehG>FVx(RP{rd$%aSY_tB}-9L z^8U#{>f>HOO842fe45+{q^6xCuruPRVr|i4WKaztwUqTc-Jk-tQV@{E`9+h)?3`S1 znd;Ix)O%?0G1;!{^ewv2dFN9f7n6KIODk7I(0u2rT4EWCNmC>#R2x+$ zR6e7nm9fbAwUDwFxsjGw)?!-dBuU{EXZji;T}v(CEJ@8#q_dW^H`p0WOFi*XExDXU zEpL*fdWg)XC6x`5ty)NVi+U6?e_fW^G*~ui`^!g|YGjk75Iy6?2-T*LdJ~~W&{H)T zX?JSL6)fsc*(IroURY{{Aho-TBy|Aiq}8k&q#gp-4xFMTl@C%2qvKm~4yk)-X%#K< zel4VuMID4u!^^K(Hb~tLu9lu7I8#2fX0#SwH&h*jPy@(KdTuwtQQo{BL0WQUi<$@* z_(<2F(!T;12F^)KtsJB}p_jUVQ@{ngfN3F^Aocf*oLpZE@wKQs^GH%NT?5UeI^~t5 zs^Db3=pex<8Vr_}dI5z_vZw9LD_uwdJGAl@S z(~|uyrjGEJ04>EoRGzMd1Xz?S`L*Ez5vFk%vF)@`0imYv?4hczlGH|*O+l!Y9=c%< z`NM--=(2GLHPb_v5Nbjp*;`8vw8+P`v_Ol=7b|g$mL3>t((Iw<2u10#wnZeVs~*~8 z4;8}1#btvL>a53}N2sG7s))hMLlY5dr=?U6b4H+*mVzPzFemcRM1)%Cva1L+(?jL3 ztKgwTd+4}5yUiTcX416(y~NabD$ zZGS|BTCF7aTUo1FDM%d#j=T??Z;<@CmT0jkFG_0rEfJ>F(vnn1ORIulI>-xw` zcGf~_S=6fKC8-l+NQFGsgQI~1Z}bgPr3x6_;Bsn7VL@tbaAdTzCbtN70n<>A!e}z7 z6?Oj#3suh|)D3doWmQ`xNs0r9dLr{z;K=cHlhk^ZC25MCI|r_pkxs6zh19jEQ>yS0 z%d7VdxH{0_hI*oIHNoZ7=aM1d_*_yGRZj!g&&W&d>1R}$nxz4z5~?nrbykCGW!HRZ zkE1!ERX_%d)I;Ff*fm|z2Q-uEgIs+E&Qa&)ff>2&;2i0)SIfwC1{ZDQDIeC78(NfS z)wEX)Bjlo5NF$5d4IPhig|Qkq%8Jhv`-3HEusyCarWf9P)E)D|VOsPJRbL@A$gb4~ zolo7tD}M>xFgw>IG-LA?L`QSeXtEpvu9>b$+(mG_BWN~r3pc7uaUX-@)g^B_4X%ft zjvDkM43940xPN{Ij`B3=9hlD|NoXlsUVS>Q3yzmSc^(7DOQ&4i;qc^>vcAm3X~~fm zbrodfSWX&^iga+48g7FQ9}h1fjyvRWa4=tRCaffl>|7Uc_3YegaAYl?dlfCYxkU{_ z!Mxkal6l~`)1{W9**UR=MUK^yTUgY?DDWes#a!SYq~@-RCnex`zYPROaj>;fkoqk+ z-X>JjU=+`7C2k}*5mzfn`L>=myj6tiTA$A&l0HD&fTQYU*Bfz}h*R~U?AE}@jEwCF z4sJ@C<%3K!zy)h5l|$995F&G2^oB2lA>BgfXdGF=kvUjXS+ENjDoWMoH#am}J6d?< z{#s&Ni(0)gpZ(A)sMQ2;lu(~IO((!L(8B#fzEK zRe~vro#AM$xQpoXnR36C_NsG)8q%739~9wzRHN{wd5`q)dTs!orD=3!BLY# zze|w3K}+mvQS-Dl+7E`xO|-PG7E@U`SClrYQ>Z*yOYCMb-2~rS8`U{ft=e9a(5T3v zW}_gJ6H)c1QOQc+{9G!a^2 ztVNALPej0KXRW4RkZB~iFl|(9sJu@L>0vRw2H%KywO$nW9*mbBLGlzWt%pSoh&E)Y z<%7`oi9IcHvX+eRx>_2(KhZ+sEb>Jy5#QysWPJC~((rvw3yHU=1!5%$lL((5rlXo@tVUW$Z;Lt(GO7~H_YE?A z39g0~-XqlH6_1A1_n_?&q6%V4s}v+NEwPV9y$o43y=d~soN!GVL>T-XgVe6z;Goov zaGX!UQU0iQr6A>(UfTY?5o*=mJWE|D57I*VS=8;2k#Ugqrk+ghXOX*WY52y9-rs_? zKC!<=z24Wpi^c5JT1$g$Q$PEBhkpDW9OaWkONt9J`S+(Qgp|IaroIR@*1}^#)qM!@ z;X}It_W{OW0p~Il91Rw>Vy|r0n$<0&JuU5`UVxOmA1h3^AXD*yw7bH_QH?{$?lbBk za5P`@p_M~R8)VUf7X)hq7_Mm&EUI6E-B)9251p1^kxytLgDq-3EP2#q7{ZOHiIWFg zOG(a)eszq2CeesE3-3l%)20Xahn# z_6rJV1!sTzjk$yAmNT`val2l#AM89F`5ecQJz1Dd1{= z<16?Ha5VSel52ag%Xr?ne39!2j@BMHEZk@YxGv!I>0e13ukE)+$o^WI75kkD_I@uD zBrntwZ5H($WZ0L%qp&2&IkhyK1@lQrlEpN2B6h~wsEJ|Dll9$aQmCoa6d0k6LMQW;O|q!Yv-xDIUn^s3NStg@8DybIRZvT<8KmA3oIX{n zT{S*^a<24TxEIeKPB0Bzl%4xI6BjuT&Sa0<3a+!A%gHiwao{@IH|m{u&&2hogB(;I{^U=V7% zR3AhL?Li2G7a@;jk`%9p#vp`Y4B7VxVOl^aV!5HW0wD}k$fOlU&Ycm$0EFxaLcUu1 z;8689gd%j4k`jW{)K$iIowyRKjV(EGW5LmQ!;}o}b|%iU#(p(~xJ8+`7vMVB(=}gf zY<4M^4dB|?c!xgTOrig{rjII0m=L-_4oaADwZ%cMh) z>a&4Q42mWX4l;EFS5*ry8>%iqh_rYP--D|I&ZO^UeK+znd~e(f9Ce{SDoh)}h3VVI zA2({R=0|A57T8p;P5g>Eo0i%zNFJ_*EVRhmwZw%M(|yEX=d};@OlIrSuP3@uC_R!b%kmpXJr$!-E6YZCmHo2xkyCkWymeMek zHsdQSrs8|xV_M3JP?OCbx`9v!T^76-{-}rMBh*O`{f1C?J=AU=E=u*#X9#gw$z-~K zrTiu!)I$$F;<8m?TJma(JW)$qZ806+Pj??vR)?y&4scJ_7b8;~xM2NW({hA@wD65# z&IsVX%K9+pgZidqO{ggfp$1wyLK_gOqlX?N)JhN4Ofh07Bh*BfT}BA=HZnP+?<0^j z79pN=142BzX9)2G;fD>`c!Y3oYHgS^0zA^|GsBWtgm}^|2=VNm*<~TAMvCzWVMB#t zPa=ek3_^uIH_~=Si08b)E_;TMo}U`{1z#8Va@{u*w+0;bF6K*Iw*Lr@#tL7rQ;rxH z@pMsI;;6BMr7=4U9L*8XL|o*Td=}!|S#aGE$M2EU`3m-O4w}=!b+U60z;)rd#0I+@ zGwxB)G;j`FA3cgLV#AK}A#I{*z-1=58i->9$I8C6`*b-6Nj--oT6H;*vDA^^s9u<1 zY6Ph}z*W~dWbN_-T!bSjwzBn4qBSAWZvdD!fP-H&3CoT^hBBbo?i8QYu-T~->N(#SalpE&GdL`^Go2!Hk8&PNX>BueF_e@+J@jR_gUP;(MI(N zH7!OcSPv~YM`ML#wa(MnqtJ4Mu)pXNs{D9f+rK+P?R|ky#pqUCd>jK8j7(uCE>9#a zagRlAp(XFJsBL8cIJ-Son~MyQ7#3cpMPoeW?I)zCwa z5W@BgvfwM^fD{^uP%S-l5g}arL6-Mxl&pukBZM2+eL~fv2>I)F;(>!R7(Gih=o_Oy z(4MBT;3Bl}ePLS20gK7yDw1fU+J%~`BZPb2$a*G1{V1foxvEt^7$JY8B_6b>Uw_M+ z9m_&MkX%MfJ7|%IXdx*U_48}|&XT0bNkOXVJAOGM0)>JoA-tU)M$uhd{X<9@H|FeLq6(c>Lcfi`c0eky zJ&5w}1fu-9(9Vt`x`GJ1f#}l>M2ayW`eZ~(7^_P;Qo0_3&xo>?a4*A~pYJABuC2wbOsiXQGZHUkiG;`&=p^l0Bx$ry924{U>AYWKr$#6i2qU#`VvS%dV)?L zLYf2m1E~cD11V~V;D-aLMQlLQvra;Q3j72}U7!h`0V#vUK>84p0n3DZwZIKPO1}k2 zA41}{i}*c2D)1l>|D_cA5{Q;7CLM=>3O*+yGNPiTeC?*4`r1XG@4kUXUTFRVB;|Vo z?-PShMx-`P6Y;M^JRt@D5Ii9TU*n6a_7<2Mn2UNyAjRia#3X=#ZmKJg8qX6*y2bEC zZB+tD2}%N~N)>?A7FB@sA*6!*g}j=O6OvwaAc?~8MfuePQrp#63gTN2@B$hjLe?IC z<3>}p)Ce3oVH+T2(*cP8QYU;-ab1DbZv9BERr=D+O2&^Au~r}zHjZL}69i5o1|LGo zWh#*LW&){|mWX&lGVfC$jjd!LMIFEwHNxjWs>Cs19^e&fP0H{p1ccuK=|f2TO(2PG z3ppXR>Kz~%@RQ*00SkbCCFE~`_%F%GgyaQ*)GS^=GPoiT|D`HU81&>H7UBd_u$JHn zDWf`qC!`i@0whD43j7F2HEb>THUirUYzL&}Fa}8Z#R92+;;jf!lk^vY1Rym{5|C;* z0Z0iZ0ZDH<5dWoF^z{ysL9>ONkkZW&d`6^v=CBIwa(u3HcEq8FEzMmq7XuQvN4^?%V0 z{}A=^|4$00#;z(V=s=WD?G}UxiVGDAgd~p;d{#*9Y(abxV0)qWAEWi3Cj1|zvwn!h zRMQTkz>G)(rIU~ol3r&Zd1!Ya%|!!5{Cm*)cM6%}X-E%6hSW8QKr&zikP03ta1@X} z8IcScC-f%>oTTfa|9OBTSv*H%@c$BN$FzpZA^+C{|Hnuzw+;sA0R-O}hhf!ek(=LnndMMQ4CCI_?4KlLhMKXBF|j1pi|>FaIAIPzL zkUoSoG0hS2^93#zxEx67R|$R{P#8=}HKuT#s`ifNWA*9ZE10<0X^@u>K z27O2*#5(@NJ4jaDoQlYd%>55;Awm0paR*6ES#*nu-1Wn6A@O$XiZs-M-9Yr|2BMCN z0nsNTQUzmmDMw1z!&L}+WWaxKAxUdRb7Y0oZtFnQo*RT-Mx^%NEaX|?zqgR6<^H{e zM7NT#Mg8{{61?HxTSzov{Cf-O-&;uk-a^7a_`kSkMH@@Uts@$K#A823>-WF6kpAjc z677I8-a4YDBc9gve{UiElUq&y-a;ZD!Yw6!J(R`0C5rz?cXs}>bmDsKpBV7(EhK8{ z|MC{ozqgQZ=N3y7O{M?dLZVjq+gnJLXgT`eTSz+>+znmIDvpzz$fsH2IJqqwHcrmX zu8)%&%V$}+@p4=F97`fN&#n?&V15$-7ujTjOYAnmWfn3KaD~kx_?q1(_=eS-1h~o; z5PZv?5nN*pCj-7?D+sQ$G=dwf`4qrSwt?Ullcxf1v-Sjc*mi>NnfeLf2Np$emn9SY z$nsAE{KVo3?y*#Y`^;lHs^LBz)fhD$)p*E`Lm)q5J~IH1St7yD>^#8}R&FNXDN7=F z#;y|l!u)0der1yheq*-@erF-G0ngbif*0&QfZdyoLYitQB#kZ5AegT~kWPX>Si?CG zG?)XymN^ilvor|gH>~+wxvlb6W*g_qI3L2~d63GiJ%Pfu6F4!I0i0PBfr%v(WMlc~ z1G2Mt0vDD_pfZmIfE;WfK~8p@AQ$sl2*}M63G%S>1bJDxMSy%Pi6B3_N>G6LEd~^1 zlL=heZGu89WC@@!n?>Np?gQApB~;U;sD}qzu#{@Llxj)>Gi$hvYPyVSx(tG%ER6&& zNzi#Y1YT^zatPKhhamR~2z*%k6%e#r0YM50N-%XL1THHf=(`evQY@JS`$$l96$E8i z{3;0IRzYx@1m&2=Y6#p{LojMJ1QpnE5*#Bzr8N*#Vu@=Y7`6t2>m;bc%B_W<>{ zt%bmkT_wRcBnV#zK>(Y)4uVPRAb3oIKo+tdg5dQKELjgh5W7!;dn9PO0fG>=U;_m6 zH$ae1f>73QBLodLLa=2c1mP@=1TRU@c@qQ{wqX+l>o-A=dou(zS^LcpwA&0p3JGd6 zbqfS8TOjDW1%kRPnFRYtP;@H<^;!H@2;#OvaGC@Sna4H=+_yn6Y8wQN*>MsaBSEE4 zA!y1HKZRh}rx08xK_n};9fGpkA(*xug68Zh3BDmg_znnKvdKFjn6v|e$0TUYLUuwB zyc2>YJ0WPx?vvmi37YPLpgmi#3xfH(AV?=cN7isR1PyjWuw^#{omm6V>=jF$4Fe-;uk@g^WMc64NKXa<2rwDz|0>e~_&v zy+^318PsXcrbAb7$bAP}_>CHFx&e6J+bD<=)U=cfXNG`S)%pT7S z=KS;C`-h$WZ?b#`Z9--o&!eZT+l8zX^vDo8F1AC+x{3U7 zi(A?$WYLi6k7?=g`Ys`i5s9e<_6S)|k(i#44iK_&HS!{qhW9`XmC$80z1ZLN**o74-x)6SC1jsuVp%I!X!f z84DQ&=?avNqERjBJS54yKyV{facph8 zQk)I$s(4!IfXGhJF3=v(UJ!W~ITJlLjt8-4qYgOE77yTsOsRwIE1OipdDN44})VO`y%7 zEugKSZ6La)zYlr@dJOs*^aS(_^c(1R&~wlW&`VGn=oN?#!W;(uh;picd_jI7e^3DI z`2zNoI*#&Bflh5tnqI9}xB8W~uk#{G7#)9ZLRxC3`D-{aRZ8y4AM+d7dz#7i1R1bW;j9PewcK2OR(%1f_rufj$GJf}VkX z0sRW1XZ-%4Ku|SM5GWWF0;&!Q1BJ6uF^ZeD9)k5jbn=c)R__4q1nmMnLfs#Oo`9Z$ zo`E`mI)Yk*=u{33V;7JL$^ps=$_2^|$^*&^$_L61DgdGbnQ|Sx>pMti7 zc7Qg4;z7MYy+Qpz?Ll2YT|sq0k6_%-pr@eUK)-{YgI<7Mg6K4Uc90851?2$c1bqrh zhv9EPX`ts+6`EhXK{Th72GKl1^9Icmxj-~GJjGz6>41j+Ob`wEFc6)`E(IzBDhoY$vU zT%g<_ItTI-=sVCY5S-IHAb(IGC5E0 z0ZIYQ1>w5N0L4EB z{Q~+8v>LP)M5p#=gXV%5Xg;V3C=x`sYLh@~QN|c}4-joa;y}l#osco@a_)exfR0fFK2L4>%Gij) z)Zx_elm|HkX#n)epeqK4aCPGhPp_#Ghn1&A+* z_KAML%AoQfnxRUAJlV6+O2K&A%Ni>?*+WIG0MUCyC`+2uXmTqKqJ1jubH^dR42Vpi z;wFGfgJ{275=e2BFYSp)hw?1TR#=sS%w|>GbC*Eec*yiLY>id%D)+vmlznFP$5y3G z(Xz;aDqjv*TUbdlno!CxKO5$vib91dQ3+u(hLTW@G?K_bsxTQyznU@PsO_lTjLH!P zf$spKIjuFQ6{s5M6!LBfr1(#u*8*4z)SP~R(ink8AX*|m0@VXG1T_HB*jSFlwSmDP zN>l?B3aSnY0bxawEWij5Eits7ebzUyo?$faqS^%2Wkr~WG(CWm(tbnq!zj1?QBlx7XaC~<+tJ8ssVUHnJ;F~1M>&bAmT*t^=+ z0I`3yEh}ZKSYdo@w2Ry&uyRmktQSt~?HI))Fh8=#HG^$H`<6#M@2#F!R_;=@asVlm zLQH#5vi=6HzTQ+cXh{4`4SlKqgb1 zQP)IP36(b+?

ySV}|oeliN0n6CHJWd|$&?ia}^ZWZ-)T{-| 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 WmMaaBolC$w7s$?K*pWl>%>NGwd^c?X