@@ -4,6 +4,7 @@ import * as DropdownMenu from '@radix-ui/react-dropdown-menu'
44import { Link } from '@tanstack/react-router'
55import { CaretDown , CircleNotch , User } from '@phosphor-icons/react'
66import type { MarkdownHeading } from '~/utils/markdown'
7+ import type { LibraryId } from '~/libraries/ids'
78
89/**
910 * New-system DS components — built on the Figma semantic tokens (action-*,
@@ -31,13 +32,29 @@ type ButtonColor =
3132type ButtonSize = 'xs' | 'sm' | 'md' | 'lg' | 'icon-sm' | 'icon-md'
3233type ButtonRounded = 'none' | 'md' | 'lg' | 'full'
3334
34- interface ButtonProps extends React . ButtonHTMLAttributes < HTMLButtonElement > {
35+ // Polymorphic: defaults to <button>, but `as={Link}` / `as="a"` lets a CTA
36+ // render as a link while keeping the button styling. Mirrors the production
37+ // Button's API (src/ui/Button.tsx) so pages can swap imports 1:1.
38+ type ButtonOwnProps < TElement extends React . ElementType = 'button' > = {
39+ as ?: TElement
40+ children : React . ReactNode
3541 variant ?: ButtonVariant
3642 color ?: ButtonColor
3743 size ?: ButtonSize
3844 rounded ?: ButtonRounded
45+ className ?: string
3946}
4047
48+ type ButtonProps < TElement extends React . ElementType = 'button' > =
49+ ButtonOwnProps < TElement > &
50+ Omit < React . ComponentPropsWithRef < TElement > , keyof ButtonOwnProps < TElement > >
51+
52+ type ButtonComponent = < TElement extends React . ElementType = 'button' > (
53+ props : ButtonProps < TElement > ,
54+ ) => React . ReactNode
55+
56+ type ButtonInnerProps = ButtonOwnProps & Record < string , unknown >
57+
4158// Color set mapped onto the new palette (brand blue = the teal #013e53).
4259const primaryColorStyles : Record < ButtonColor , string > = {
4360 blue : 'bg-ds-blue-500 text-white border-ds-blue-500 hover:bg-ds-blue-400' ,
@@ -119,48 +136,49 @@ function getDefaultRounded(size: ButtonSize): ButtonRounded {
119136 return 'lg'
120137}
121138
122- export const Button = React . forwardRef < HTMLButtonElement , ButtonProps > (
123- function Button (
139+ export const Button : ButtonComponent = React . forwardRef <
140+ HTMLElement ,
141+ ButtonInnerProps
142+ > ( function Button ( props , ref ) {
143+ const {
144+ as,
145+ children,
146+ variant = 'primary' ,
147+ color = 'blue' ,
148+ size,
149+ rounded,
150+ className,
151+ ...rest
152+ } = props as ButtonOwnProps & Record < string , unknown >
153+ const Component = as || 'button'
154+ const resolvedSize = size ?? getDefaultSize ( variant )
155+ const resolvedRounded = rounded ?? getDefaultRounded ( resolvedSize )
156+ const colorStyles =
157+ variant === 'primary'
158+ ? primaryColorStyles [ color ]
159+ : variant === 'icon'
160+ ? iconColorStyles [ color ]
161+ : variant === 'link'
162+ ? linkColorStyles [ color ]
163+ : ''
164+
165+ return React . createElement (
166+ Component ,
124167 {
125- children,
126- variant = 'primary' ,
127- color = 'blue' ,
128- size,
129- rounded,
130- className,
131- ...rest
168+ ref,
169+ className : twMerge (
170+ baseStyles ,
171+ variantStyles [ variant ] ,
172+ sizeStyles [ resolvedSize ] ,
173+ roundedStyles [ resolvedRounded ] ,
174+ colorStyles ,
175+ className ,
176+ ) ,
177+ ...rest ,
132178 } ,
133- ref ,
134- ) {
135- const resolvedSize = size ?? getDefaultSize ( variant )
136- const resolvedRounded = rounded ?? getDefaultRounded ( resolvedSize )
137- const colorStyles =
138- variant === 'primary'
139- ? primaryColorStyles [ color ]
140- : variant === 'icon'
141- ? iconColorStyles [ color ]
142- : variant === 'link'
143- ? linkColorStyles [ color ]
144- : ''
145-
146- return (
147- < button
148- ref = { ref }
149- className = { twMerge (
150- baseStyles ,
151- variantStyles [ variant ] ,
152- sizeStyles [ resolvedSize ] ,
153- roundedStyles [ resolvedRounded ] ,
154- colorStyles ,
155- className ,
156- ) }
157- { ...rest }
158- >
159- { children }
160- </ button >
161- )
162- } ,
163- )
179+ children ,
180+ )
181+ } ) as unknown as ButtonComponent
164182
165183/* ------------------------------------------------------------------ Badge -- */
166184
@@ -207,6 +225,83 @@ export function Badge({
207225 )
208226}
209227
228+ /* ---------------------------------------------------------------- Eyebrow -- */
229+
230+ type EyebrowTone = 'muted' | 'secondary' | 'accent'
231+
232+ const eyebrowToneStyles : Record < EyebrowTone , string > = {
233+ muted : 'text-text-muted' ,
234+ secondary : 'text-text-secondary' ,
235+ accent : 'text-text-accent' ,
236+ }
237+
238+ // Libraries that ship a `--color-lib-*` brand token. Written as literal classes
239+ // so Tailwind's JIT emits each `text-lib-*` utility. Libraries without a brand
240+ // token (e.g. ranger, config) simply fall back to the neutral tone.
241+ const eyebrowLibraryStyles : Partial < Record < LibraryId , string > > = {
242+ start : 'text-lib-start' ,
243+ router : 'text-lib-router' ,
244+ query : 'text-lib-query' ,
245+ table : 'text-lib-table' ,
246+ db : 'text-lib-db' ,
247+ ai : 'text-lib-ai' ,
248+ form : 'text-lib-form' ,
249+ virtual : 'text-lib-virtual' ,
250+ pacer : 'text-lib-pacer' ,
251+ hotkeys : 'text-lib-hotkeys' ,
252+ store : 'text-lib-store' ,
253+ devtools : 'text-lib-devtools' ,
254+ cli : 'text-lib-cli' ,
255+ intent : 'text-lib-intent' ,
256+ }
257+
258+ /**
259+ * Section eyebrow / kicker — the small uppercase label that sits above a
260+ * heading. Locks in the DS `mono-caps` role (IBM Plex Mono, 12px, +1.5px
261+ * tracking) plus the inline icon layout, so every eyebrow across the site stays
262+ * on-system instead of hand-stacking `text-xs font-black uppercase`.
263+ *
264+ * Color resolves in priority order: `library` (brand the eyebrow by the
265+ * category it sits within, via that library's `--color-lib-*` token) →
266+ * `className` override → `tone` (neutral default). Pass a `library` to brand it,
267+ * omit it for a neutral eyebrow.
268+ */
269+ export function Eyebrow ( {
270+ children,
271+ icon,
272+ tone = 'secondary' ,
273+ library,
274+ className,
275+ } : {
276+ children : React . ReactNode
277+ icon ?: React . ReactNode
278+ tone ?: EyebrowTone
279+ /** Brand the eyebrow by the library/category it's nested within. Falls back
280+ * to `tone` when unset or when the library has no brand token. */
281+ library ?: LibraryId
282+ className ?: string
283+ } ) {
284+ const color =
285+ ( library && eyebrowLibraryStyles [ library ] ) ?? eyebrowToneStyles [ tone ]
286+
287+ // The `mono-caps` type role is a fixed base — never merged away — so the DS
288+ // font/size/tracking can't be overridden. Only color (library / tone /
289+ // className) goes through twMerge, which handles standard color utilities
290+ // cleanly. This also sidesteps twMerge dropping the custom `text-ds-*` size
291+ // when a caller passes a text color.
292+ return (
293+ < p
294+ className = { `inline-flex items-center gap-2 font-ds-mono text-ds-mono-caps uppercase ${ twMerge (
295+ color ,
296+ className ,
297+ ) } `}
298+ >
299+ { icon }
300+ { children }
301+ </ p >
302+ )
303+ }
304+
210305/* -------------------------------------------------------------- FormInput -- */
211306
212307type FormInputProps = React . InputHTMLAttributes < HTMLInputElement > & {
0 commit comments