@goldencomm/baseline-registry 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/README.md +26 -2
  2. package/package.json +15 -4
  3. package/r/accordion-contained.json +44 -0
  4. package/r/accordion-split.json +44 -0
  5. package/r/accordion.json +43 -0
  6. package/r/alert.json +27 -0
  7. package/r/aspect-ratio.json +24 -0
  8. package/r/avatar.json +27 -0
  9. package/r/badge.json +28 -0
  10. package/r/base.json +1058 -0
  11. package/r/blog-card-grid.json +30 -0
  12. package/r/breadcrumb.json +28 -0
  13. package/r/button-group.json +28 -0
  14. package/r/button.json +28 -0
  15. package/r/card.json +24 -0
  16. package/r/carousel.json +29 -0
  17. package/r/checkbox.json +25 -0
  18. package/r/columns.json +24 -0
  19. package/r/contact-centered.json +32 -0
  20. package/r/container.json +27 -0
  21. package/r/cta-left.json +28 -0
  22. package/r/dialog.json +31 -0
  23. package/r/drawer.json +27 -0
  24. package/r/dropdown-menu.json +28 -0
  25. package/r/faq-centered.json +28 -0
  26. package/r/field.json +29 -0
  27. package/r/header-inline.json +33 -0
  28. package/r/header-stacked.json +34 -0
  29. package/r/hero-centered.json +28 -0
  30. package/r/hero-split.json +29 -0
  31. package/r/hover-card.json +29 -0
  32. package/r/icon.json +30 -0
  33. package/r/input-group.json +30 -0
  34. package/r/input-otp.json +28 -0
  35. package/r/input.json +27 -0
  36. package/r/interest.json +15 -0
  37. package/r/item.json +29 -0
  38. package/r/kbd.json +24 -0
  39. package/r/label.json +24 -0
  40. package/r/media.json +33 -0
  41. package/r/meter.json +24 -0
  42. package/r/native-select.json +25 -0
  43. package/r/navigation-menu.json +30 -0
  44. package/r/pagination-bordered.json +27 -0
  45. package/r/pagination-grouped.json +27 -0
  46. package/r/pagination.json +26 -0
  47. package/r/panel.json +27 -0
  48. package/r/popover.json +28 -0
  49. package/r/progress.json +34 -0
  50. package/r/radio.json +24 -0
  51. package/r/registry.json +2770 -0
  52. package/r/section.json +27 -0
  53. package/r/select.json +28 -0
  54. package/r/separator.json +27 -0
  55. package/r/sheet.json +27 -0
  56. package/r/sidebar.json +34 -0
  57. package/r/spinner.json +25 -0
  58. package/r/switch.json +27 -0
  59. package/r/table.json +24 -0
  60. package/r/tabs-line.json +28 -0
  61. package/r/tabs.json +27 -0
  62. package/r/text-styles.json +16 -0
  63. package/r/textarea.json +24 -0
  64. package/r/toggle-group.json +52 -0
  65. package/r/toggle.json +27 -0
  66. package/r/tooltip.json +62 -0
  67. package/r/typeset.json +16 -0
  68. package/r/utils.json +18 -0
@@ -0,0 +1,28 @@
1
+ {
2
+ "$schema": "https://ui.shadcn.com/schema/registry-item.json",
3
+ "name": "faq-centered",
4
+ "title": "Faq Centered",
5
+ "description": "Baseline block faq-centered (draft): a composed section to copy and own. Spec: components/sections/spec.md",
6
+ "registryDependencies": [
7
+ "@baseline/utils",
8
+ "@baseline/section",
9
+ "@baseline/container",
10
+ "@baseline/columns",
11
+ "@baseline/accordion"
12
+ ],
13
+ "files": [
14
+ {
15
+ "path": "src/blocks/faq-centered/faq-centered.tsx",
16
+ "content": "// baseline:faq-centered@0.1.0\n// Baseline block (spec: components/sections/spec.md; Figma FAQ/Centered 2443:402). A composed section to copy and own:\n// Section > Container (content) > a heading and lead (Columns/1), then the questions as an Accordion (Columns/1). The\n// Accordion draws in the project's installed look (ADR-0031; Figma shows contained); one answer open at a time.\nimport { Section } from \"@/registry/ui/section/section\";\nimport { Container } from \"@/registry/ui/container/container\";\nimport { Columns } from \"@/registry/ui/columns/columns\";\nimport { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from \"@/registry/ui/accordion/accordion\";\n\ntype FaqItem = { question: string; answer: string };\n\ntype FaqCenteredProps = {\n id?: string;\n title: string;\n description?: string;\n items: FaqItem[];\n};\n\nfunction FaqCentered({ id = \"faq\", title, description, items }: FaqCenteredProps) {\n return (\n <Section aria-labelledby={`${id}-title`}>\n <Container width=\"content\">\n <Columns columns={1}>\n <div>\n <h2 id={`${id}-title`} className=\"heading-xl text-balance\">\n {title}\n </h2>\n {description ? <p className=\"mt-5 text-base leading-7 text-pretty text-muted-foreground\">{description}</p> : null}\n </div>\n </Columns>\n <Columns columns={1}>\n <Accordion defaultValue={items.length ? [`${id}-0`] : []}>\n {items.map((item, i) => (\n <AccordionItem key={item.question} value={`${id}-${i}`}>\n <AccordionTrigger>{item.question}</AccordionTrigger>\n <AccordionContent>\n <p>{item.answer}</p>\n </AccordionContent>\n </AccordionItem>\n ))}\n </Accordion>\n </Columns>\n </Container>\n </Section>\n );\n}\n\nexport { FaqCentered };\nexport type { FaqCenteredProps, FaqItem };\n",
17
+ "type": "registry:component",
18
+ "target": "components/faq-centered.tsx"
19
+ }
20
+ ],
21
+ "meta": {
22
+ "spec": "components/sections/spec.md",
23
+ "figma": "2398:23021",
24
+ "status": "draft",
25
+ "block": true
26
+ },
27
+ "type": "registry:block"
28
+ }
package/r/field.json ADDED
@@ -0,0 +1,29 @@
1
+ {
2
+ "$schema": "https://ui.shadcn.com/schema/registry-item.json",
3
+ "name": "field",
4
+ "title": "Field",
5
+ "description": "Baseline field (ready). Spec: components/field/spec.md",
6
+ "dependencies": [
7
+ "class-variance-authority"
8
+ ],
9
+ "registryDependencies": [
10
+ "@baseline/utils",
11
+ "@baseline/label",
12
+ "@baseline/separator"
13
+ ],
14
+ "files": [
15
+ {
16
+ "path": "src/ui/field/field.tsx",
17
+ "content": "// baseline:field@0.1.0\n// Fork of shadcn/ui Field (base-ui flavour, source read 2026-09-25). Spec: components/field/spec.md.\n// Geometry AND typography come from Figma through the field/* and label/* component tokens (ADR-0017).\n// Until 2026-09-25 the text sizes, line heights and weights were fixed Tailwind classes, and they had drifted\n// from Figma: label, error and legend all rendered lighter than the design specified.\n//\n// One deliberate difference from upstream, recorded in the spec: upstream's useMemo in FieldError is dropped.\n// De-duplicating a short array is cheaper than a hook, and without it every part here is server-safe, so this\n// file carries no 'use client' (RSC policy, PLAN §4.4).\nimport * as React from \"react\";\nimport { cva, type VariantProps } from \"class-variance-authority\";\nimport { cn } from \"@/lib/utils\";\nimport { Label } from \"@/registry/ui/label/label\";\nimport { Separator } from \"@/registry/ui/separator/separator\";\n\nfunction FieldSet({ className, ...props }: React.ComponentProps<\"fieldset\">) {\n return (\n <fieldset\n data-slot=\"field-set\"\n className={cn(\n \"flex flex-col gap-(--bl-field-fieldset-gap)\",\n // RadioGroup is a real component and sets this slot. There is deliberately no CheckboxGroup, here or\n // upstream: a checkbox set is FieldSet + FieldGroup, and the caller tightens the stack with\n // gap-(--bl-field-group-gap-compact). The matching checkbox-group selectors were dead and are gone.\n \"has-[>[data-slot=radio-group]]:gap-(--bl-field-group-gap-compact)\",\n className,\n )}\n {...props}\n />\n );\n}\n\n/**\n * The caption for a FieldSet. `legend` is the section heading; `label` is the quieter form used when the set\n * sits inside another group, and it deliberately borrows the label tokens so the two agree.\n */\nfunction FieldLegend({\n className,\n variant = \"legend\",\n ...props\n}: React.ComponentProps<\"legend\"> & { variant?: \"legend\" | \"label\" }) {\n return (\n <legend\n data-slot=\"field-legend\"\n data-variant={variant}\n className={cn(\n \"mb-(--bl-field-gap)\",\n \"data-[variant=legend]:text-(length:--bl-field-legend-font-size)\",\n \"data-[variant=legend]:leading-(--bl-field-legend-line-height)\",\n \"data-[variant=legend]:font-(--bl-field-legend-font-weight)\",\n \"data-[variant=label]:text-(length:--bl-label-font-size)\",\n \"data-[variant=label]:leading-(--bl-label-line-height)\",\n \"data-[variant=label]:font-(--bl-label-font-weight)\",\n className,\n )}\n {...props}\n />\n );\n}\n\n/**\n * Stacks fields. Upstream's FieldGroup is always a vertical stack and Baseline matches it: arranging sibling\n * fields in a row is a page layout decision, so use a grid or a className on this element. The Figma set's\n * Orientation axis was removed on 2026-09-25 for the same reason.\n */\nfunction FieldGroup({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n <div\n data-slot=\"field-group\"\n className={cn(\n \"group/field-group @container/field-group flex w-full flex-col gap-(--bl-field-group-gap)\",\n \"*:data-[slot=field-group]:gap-(--bl-field-fieldset-gap)\",\n className,\n )}\n {...props}\n />\n );\n}\n\nconst fieldVariants = cva(\n \"group/field flex w-full gap-(--bl-field-gap) data-[invalid=true]:text-destructive\",\n {\n variants: {\n orientation: {\n vertical: \"flex-col *:w-full [&>.sr-only]:w-auto\",\n horizontal:\n \"flex-row items-center has-[>[data-slot=field-content]]:items-start *:data-[slot=field-label]:flex-auto has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px\",\n responsive:\n \"flex-col *:w-full @md/field-group:flex-row @md/field-group:items-center @md/field-group:*:w-auto @md/field-group:has-[>[data-slot=field-content]]:items-start @md/field-group:*:data-[slot=field-label]:flex-auto [&>.sr-only]:w-auto @md/field-group:has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px\",\n },\n },\n defaultVariants: { orientation: \"vertical\" },\n },\n);\n\n/**\n * Field gives a control its label, description and error. The control stays a separate component and is\n * composed in, the way Figma's `Control` swap accepts Input, Select, Textarea, Input Group or Input OTP.\n */\nfunction Field({\n className,\n orientation = \"vertical\",\n ...props\n}: React.ComponentProps<\"div\"> & VariantProps<typeof fieldVariants>) {\n return (\n <div\n role=\"group\"\n data-slot=\"field\"\n data-orientation={orientation}\n className={cn(fieldVariants({ orientation }), className)}\n {...props}\n />\n );\n}\n\n/**\n * Stacks a label and its description when the control sits beside them (horizontal fields). It sets no line\n * height of its own: the label and description each bind theirs, so a designer retunes them in one place.\n */\nfunction FieldContent({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n <div\n data-slot=\"field-content\"\n className={cn(\"group/field-content flex flex-1 flex-col gap-(--bl-field-content-gap)\", className)}\n {...props}\n />\n );\n}\n\n/**\n * The label, built on the Label component so the two never drift.\n * The `has-[>[data-slot=field]]` block is the option-card treatment: a label that wraps a whole Field becomes a\n * bordered, selectable card, which is what the field/option-card/* tokens describe. Checkbox and Radio options\n * are the components that will lean on it.\n */\nfunction FieldLabel({ className, ...props }: React.ComponentProps<typeof Label>) {\n return (\n <Label\n data-slot=\"field-label\"\n className={cn(\n \"group/field-label peer/field-label w-fit\",\n \"group-data-[disabled=true]/field:opacity-50\",\n // card treatment\n \"has-[>[data-slot=field]]:w-full has-[>[data-slot=field]]:flex-col\",\n \"has-[>[data-slot=field]]:rounded-(--bl-field-option-card-radius)\",\n \"has-[>[data-slot=field]]:border-(length:--bl-field-option-card-border-width) has-[>[data-slot=field]]:border-border\",\n \"*:data-[slot=field]:p-(--bl-field-option-card-padding)\",\n // Baseline's Checkbox, Radio and Switch are native inputs (ADR-0030), so the card reads :checked.\n // data-checked is kept beside it so a Base UI control swapped into the same Field still styles.\n //\n // The selected tint is a token, not `bg-primary/5`. field/option-card/{background,border}-selected are\n // Components colours of the one shape ADR-0017 §2 permits -- an alias to color/primary carrying an\n // opacity -- so the designer owns the 5 % and 30 %, and all of it still follows the colour scheme.\n // They emit as `color-mix(in oklab, var(--primary) 5%, transparent)`, which is byte-identical to what\n // Tailwind resolved `bg-primary/5` to, so this swap is visually a no-op.\n \"has-checked:border-(--bl-field-option-card-border-selected)\",\n \"has-checked:bg-(--bl-field-option-card-background-selected)\",\n \"has-data-checked:border-(--bl-field-option-card-border-selected)\",\n \"has-data-checked:bg-(--bl-field-option-card-background-selected)\",\n \"has-[>[data-slot=field]]:not-has-[:disabled,[data-disabled]]:hover:bg-muted/50\",\n \"has-[>[data-slot=field]]:has-[:focus-visible]:border-ring\",\n \"has-[>[data-slot=field]]:has-[:focus-visible]:ring-(length:--bl-control-ring-width)\",\n \"has-[>[data-slot=field]]:has-[:focus-visible]:ring-ring\",\n className,\n )}\n {...props}\n />\n );\n}\n\n/** A label for a group, which labels nothing focusable, so it is not a <label>. */\nfunction FieldTitle({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n <div\n data-slot=\"field-label\"\n className={cn(\n \"flex w-fit items-center gap-(--bl-label-gap)\",\n \"text-(length:--bl-label-font-size) leading-(--bl-label-line-height) font-(--bl-label-font-weight)\",\n \"group-data-[disabled=true]/field:opacity-50\",\n className,\n )}\n {...props}\n />\n );\n}\n\nfunction FieldDescription({ className, ...props }: React.ComponentProps<\"p\">) {\n return (\n <p\n data-slot=\"field-description\"\n className={cn(\n \"text-left text-muted-foreground\",\n \"text-(length:--bl-field-description-font-size) leading-(--bl-field-description-line-height) font-(--bl-field-description-font-weight)\",\n // Same fix as Separator: the value has to be matched. It is `group-data-`, not `group-has-data-`,\n // because Field's own root carries both `group/field` and `data-orientation` -- the old selector asked\n // whether the field CONTAINED something horizontal rather than whether it WAS horizontal.\n \"group-data-[orientation=horizontal]/field:text-balance\",\n \"[[data-variant=legend]+&]:-mt-1.5 last:mt-0 nth-last-2:-mt-1\",\n \"[&>a]:underline [&>a]:underline-offset-4 [&>a:hover]:text-primary\",\n className,\n )}\n {...props}\n />\n );\n}\n\n/**\n * A rule between fields, optionally with centred text. The rule itself is the Separator component. The caption\n * uses the description tokens because it is the same kind of secondary text, which is also how Figma binds it.\n */\nfunction FieldSeparator({\n children,\n className,\n ...props\n}: React.ComponentProps<\"div\"> & { children?: React.ReactNode }) {\n return (\n <div\n data-slot=\"field-separator\"\n data-content={!!children}\n className={cn(\n \"relative -my-2 h-5\",\n \"text-(length:--bl-field-description-font-size) leading-(--bl-field-description-line-height)\",\n \"group-data-[variant=outline]/field-group:-mb-2\",\n className,\n )}\n {...props}\n >\n <Separator className=\"absolute inset-0 top-1/2\" />\n {children ? (\n <span\n data-slot=\"field-separator-content\"\n className=\"relative mx-auto block w-fit bg-background px-(--bl-field-separator-padding-x) text-muted-foreground\"\n >\n {children}\n </span>\n ) : null}\n </div>\n );\n}\n\n/**\n * The error text. Pass `children`, or an `errors` array from a form library, which is de-duplicated by message\n * and rendered as a list when more than one survives.\n */\nfunction FieldError({\n className,\n children,\n errors,\n ...props\n}: React.ComponentProps<\"div\"> & { errors?: Array<{ message?: string } | undefined> }) {\n let content: React.ReactNode = children;\n\n if (!content && errors?.length) {\n const unique = [...new Map(errors.map((error) => [error?.message, error])).values()];\n content =\n unique.length === 1 ? (\n unique[0]?.message\n ) : (\n <ul className=\"ml-4 flex list-disc flex-col gap-1\">\n {unique.map((error, index) => (error?.message ? <li key={index}>{error.message}</li> : null))}\n </ul>\n );\n }\n\n if (!content) return null;\n\n return (\n <div\n role=\"alert\"\n data-slot=\"field-error\"\n className={cn(\n \"text-destructive\",\n \"text-(length:--bl-field-error-font-size) leading-(--bl-field-error-line-height) font-(--bl-field-error-font-weight)\",\n className,\n )}\n {...props}\n >\n {content}\n </div>\n );\n}\n\nexport {\n Field,\n FieldContent,\n FieldDescription,\n FieldError,\n FieldGroup,\n FieldLabel,\n FieldLegend,\n FieldSeparator,\n FieldSet,\n FieldTitle,\n fieldVariants,\n};\n",
18
+ "type": "registry:ui",
19
+ "target": "components/ui/field.tsx"
20
+ }
21
+ ],
22
+ "meta": {
23
+ "spec": "components/field/spec.md",
24
+ "figma": "2040:34976",
25
+ "upstream": "shadcn/ui field (base-ui flavour, source read from ui.shadcn.com 2026-09-25)",
26
+ "status": "ready"
27
+ },
28
+ "type": "registry:ui"
29
+ }
@@ -0,0 +1,33 @@
1
+ {
2
+ "$schema": "https://ui.shadcn.com/schema/registry-item.json",
3
+ "name": "header-inline",
4
+ "title": "Header Inline",
5
+ "description": "Baseline block header-inline (draft): a composed section to copy and own. Spec: components/header/spec.md",
6
+ "registryDependencies": [
7
+ "@baseline/utils",
8
+ "@baseline/section",
9
+ "@baseline/container",
10
+ "@baseline/button",
11
+ "@baseline/icon",
12
+ "@baseline/navigation-menu",
13
+ "@baseline/sheet",
14
+ "@baseline/dialog",
15
+ "@baseline/input",
16
+ "@baseline/sidebar"
17
+ ],
18
+ "files": [
19
+ {
20
+ "path": "src/blocks/header-inline/header-inline.tsx",
21
+ "content": "// baseline:header-inline@0.1.0\n// Baseline block (spec: components/header/spec.md; Figma Header/Inline 2593:8976). A composed site header to copy and\n// own: change the markup freely. Logo, navigation and actions on one row, under an optional announcement bar on the\n// muted surface. Below md the navigation moves into a Sheet drawn with the Sidebar's menu parts; Search opens a Dialog\n// that submits to the site's search page. Not sticky. Server component: the Dialog and Sheet open with invoker\n// commands, so the header works without client JavaScript (the Navigation Menu brings its own small client leaf).\nimport * as React from \"react\";\nimport { Section } from \"@/registry/ui/section/section\";\nimport { Container } from \"@/registry/ui/container/container\";\nimport { Button, buttonVariants } from \"@/registry/ui/button/button\";\nimport { Icon } from \"@/registry/ui/icon/icon\";\nimport {\n NavigationMenu,\n NavigationMenuContent,\n NavigationMenuItem,\n NavigationMenuLink,\n NavigationMenuList,\n NavigationMenuTrigger,\n navigationMenuTriggerStyle,\n} from \"@/registry/ui/navigation-menu/navigation-menu\";\nimport { SheetContent, SheetHeader, SheetTitle } from \"@/registry/ui/sheet/sheet\";\nimport { DialogContent, DialogHeader, DialogTitle } from \"@/registry/ui/dialog/dialog\";\nimport { Input } from \"@/registry/ui/input/input\";\nimport {\n SidebarMenu,\n SidebarMenuButton,\n SidebarMenuItem,\n SidebarMenuSub,\n SidebarMenuSubButton,\n SidebarMenuSubItem,\n} from \"@/registry/ui/sidebar/sidebar\";\n\ntype HeaderLink = { label: string; href: string; description?: string };\ntype HeaderNavItem = HeaderLink & { items?: HeaderLink[] };\n\ntype HeaderInlineProps = {\n /** prefix for the ids the Sheet, Dialog and menu panels need (unique per page) */\n id?: string;\n logo: React.ReactNode;\n homeHref?: string;\n /** one centred message on the muted surface; omit for no bar */\n announcement?: { text: string; link?: HeaderLink };\n nav: HeaderNavItem[];\n /** the call to action beside the actions, a link styled as a button */\n action?: HeaderLink & { icon?: React.ComponentProps<typeof Icon>[\"name\"] };\n /** the search page the dialog submits to (GET, field q); false for no search */\n search?: string | false;\n /** the account page; omit for no account link */\n accountHref?: string;\n};\n\nfunction HeaderInline({ id = \"header\", logo, homeHref = \"/\", announcement, nav, action, search = \"/search\", accountHref }: HeaderInlineProps) {\n return (\n <header data-slot=\"header\" data-layout=\"inline\">\n {announcement ? (\n <Section as=\"div\" surface=\"muted\" paddingTop=\"none\" paddingBottom=\"none\" className=\"py-2\">\n <Container width=\"wide\">\n <p className=\"text-center text-sm\">\n {announcement.text}{\" \"}\n {announcement.link ? (\n <a href={announcement.link.href} className=\"font-semibold text-foreground underline-offset-4 hover:underline\">\n {announcement.link.label}\n </a>\n ) : null}\n </p>\n </Container>\n </Section>\n ) : null}\n <Section as=\"div\" paddingTop=\"none\" paddingBottom=\"none\" className=\"border-b border-border py-4\">\n <Container width=\"wide\">\n <div className=\"flex items-center gap-4\">\n <Button variant=\"ghost\" size=\"icon-md\" className=\"md:hidden\" command=\"show-modal\" commandfor={`${id}-menu`} aria-label=\"Menu\">\n <Icon name=\"menu\" />\n </Button>\n {/* min-w-0 + truncate: a long text logo shortens instead of pushing the actions off a phone screen */}\n <a href={homeHref} className=\"flex min-w-0 items-center *:truncate\">\n {logo}\n </a>\n {/* centred between the logo and the actions, as Figma's Center frame */}\n <div className=\"hidden flex-1 justify-center md:flex\">\n <NavigationMenu aria-label=\"Main\">\n <NavigationMenuList>\n {nav.map((item, i) => (\n <NavigationMenuItem key={item.label}>\n {item.items?.length ? (\n <>\n <NavigationMenuTrigger commandfor={`${id}-nav-${i}`}>{item.label}</NavigationMenuTrigger>\n <NavigationMenuContent id={`${id}-nav-${i}`}>\n <ul className=\"grid w-72\">\n {item.items.map((link) => (\n <li key={link.href}>\n <NavigationMenuLink href={link.href}>\n <div className=\"flex flex-col gap-(--bl-navigation-menu-link-description-gap)\">\n <div className=\"leading-none font-(--bl-navigation-menu-link-title-font-weight)\">{link.label}</div>\n {link.description ? <div className=\"line-clamp-2 text-muted-foreground\">{link.description}</div> : null}\n </div>\n </NavigationMenuLink>\n </li>\n ))}\n </ul>\n </NavigationMenuContent>\n </>\n ) : (\n <NavigationMenuLink href={item.href} className={navigationMenuTriggerStyle()}>\n {item.label}\n </NavigationMenuLink>\n )}\n </NavigationMenuItem>\n ))}\n </NavigationMenuList>\n </NavigationMenu>\n </div>\n <div className=\"ms-auto flex items-center gap-4\">\n {action ? (\n <a href={action.href} className={buttonVariants()}>\n {action.icon ? <Icon name={action.icon} data-icon=\"inline-start\" /> : null}\n {action.label}\n </a>\n ) : null}\n {search || accountHref ? (\n <div className=\"flex items-center gap-1\">\n {search ? (\n <Button variant=\"ghost\" size=\"icon-md\" command=\"show-modal\" commandfor={`${id}-search`} aria-label=\"Search\">\n <Icon name=\"search\" />\n </Button>\n ) : null}\n {accountHref ? (\n <a href={accountHref} className={buttonVariants({ variant: \"ghost\", size: \"icon-md\" })} aria-label=\"Account\">\n <Icon name=\"user\" />\n </a>\n ) : null}\n </div>\n ) : null}\n </div>\n </div>\n </Container>\n </Section>\n\n <SheetContent id={`${id}-menu`} side=\"left\" className=\"bg-sidebar text-sidebar-foreground\">\n <SheetHeader>\n <SheetTitle id={`${id}-menu-title`}>Menu</SheetTitle>\n </SheetHeader>\n <nav aria-label=\"Main\" className=\"px-(--bl-sheet-padding)\">\n <SidebarMenu>\n {nav.map((item) => (\n <SidebarMenuItem key={item.label}>\n <SidebarMenuButton render={<a href={item.href} />}>{item.label}</SidebarMenuButton>\n {item.items?.length ? (\n <SidebarMenuSub>\n {item.items.map((link) => (\n <SidebarMenuSubItem key={link.href}>\n <SidebarMenuSubButton render={<a href={link.href} />}>{link.label}</SidebarMenuSubButton>\n </SidebarMenuSubItem>\n ))}\n </SidebarMenuSub>\n ) : null}\n </SidebarMenuItem>\n ))}\n </SidebarMenu>\n </nav>\n </SheetContent>\n\n {search ? (\n <DialogContent id={`${id}-search`}>\n <DialogHeader>\n <DialogTitle id={`${id}-search-title`}>Search</DialogTitle>\n </DialogHeader>\n <form role=\"search\" action={search} method=\"get\" className=\"flex gap-2\">\n <Input type=\"search\" name=\"q\" aria-label=\"Search\" placeholder=\"Search…\" className=\"flex-1\" />\n <Button type=\"submit\">Search</Button>\n </form>\n </DialogContent>\n ) : null}\n </header>\n );\n}\n\nexport { HeaderInline };\nexport type { HeaderInlineProps, HeaderLink, HeaderNavItem };\n",
22
+ "type": "registry:component",
23
+ "target": "components/header-inline.tsx"
24
+ }
25
+ ],
26
+ "meta": {
27
+ "spec": "components/header/spec.md",
28
+ "figma": "2593:8976",
29
+ "status": "draft",
30
+ "block": true
31
+ },
32
+ "type": "registry:block"
33
+ }
@@ -0,0 +1,34 @@
1
+ {
2
+ "$schema": "https://ui.shadcn.com/schema/registry-item.json",
3
+ "name": "header-stacked",
4
+ "title": "Header Stacked",
5
+ "description": "Baseline block header-stacked (draft): a composed section to copy and own. Spec: components/header/spec.md",
6
+ "registryDependencies": [
7
+ "@baseline/utils",
8
+ "@baseline/section",
9
+ "@baseline/container",
10
+ "@baseline/badge",
11
+ "@baseline/button",
12
+ "@baseline/icon",
13
+ "@baseline/navigation-menu",
14
+ "@baseline/sheet",
15
+ "@baseline/dialog",
16
+ "@baseline/input",
17
+ "@baseline/sidebar"
18
+ ],
19
+ "files": [
20
+ {
21
+ "path": "src/blocks/header-stacked/header-stacked.tsx",
22
+ "content": "// baseline:header-stacked@0.1.0\n// Baseline block (spec: components/header/spec.md; Figma Header/Stacked 2515:30410). A composed site header to copy and\n// own: change the markup freely. Logo and actions (search, account, cart) on the first row, the navigation on a row of\n// its own below. Below md the navigation row goes and moves into a Sheet drawn with the Sidebar's menu parts; Search\n// opens a Dialog that submits to the site's search page. Not sticky. Server component: the Dialog and Sheet open with\n// invoker commands (the Navigation Menu brings its own small client leaf).\nimport * as React from \"react\";\nimport { Section } from \"@/registry/ui/section/section\";\nimport { Container } from \"@/registry/ui/container/container\";\nimport { Badge } from \"@/registry/ui/badge/badge\";\nimport { Button, buttonVariants } from \"@/registry/ui/button/button\";\nimport { Icon } from \"@/registry/ui/icon/icon\";\nimport {\n NavigationMenu,\n NavigationMenuContent,\n NavigationMenuItem,\n NavigationMenuLink,\n NavigationMenuList,\n NavigationMenuTrigger,\n navigationMenuTriggerStyle,\n} from \"@/registry/ui/navigation-menu/navigation-menu\";\nimport { SheetContent, SheetHeader, SheetTitle } from \"@/registry/ui/sheet/sheet\";\nimport { DialogContent, DialogHeader, DialogTitle } from \"@/registry/ui/dialog/dialog\";\nimport { Input } from \"@/registry/ui/input/input\";\nimport {\n SidebarMenu,\n SidebarMenuButton,\n SidebarMenuItem,\n SidebarMenuSub,\n SidebarMenuSubButton,\n SidebarMenuSubItem,\n} from \"@/registry/ui/sidebar/sidebar\";\n\ntype HeaderLink = { label: string; href: string; description?: string };\ntype HeaderNavItem = HeaderLink & { items?: HeaderLink[] };\n\ntype HeaderStackedProps = {\n /** prefix for the ids the Sheet, Dialog and menu panels need (unique per page) */\n id?: string;\n logo: React.ReactNode;\n homeHref?: string;\n nav: HeaderNavItem[];\n /** the search page the dialog submits to (GET, field q); false for no search */\n search?: string | false;\n /** the account page; omit for no account link */\n accountHref?: string;\n /** the cart page and its item count; omit for no cart */\n cart?: { href: string; count?: number };\n};\n\nfunction HeaderStacked({ id = \"header\", logo, homeHref = \"/\", nav, search = \"/search\", accountHref, cart }: HeaderStackedProps) {\n const cartLabel = cart?.count ? `Cart, ${cart.count} items` : \"Cart\";\n return (\n <header data-slot=\"header\" data-layout=\"stacked\">\n <Section as=\"div\" paddingTop=\"none\" paddingBottom=\"none\" className=\"border-b border-border py-4\">\n <Container width=\"wide\">\n <div className=\"flex items-center gap-4\">\n <Button variant=\"ghost\" size=\"icon-md\" className=\"md:hidden\" command=\"show-modal\" commandfor={`${id}-menu`} aria-label=\"Menu\">\n <Icon name=\"menu\" />\n </Button>\n {/* min-w-0 + truncate: a long text logo shortens instead of pushing the actions off a phone screen */}\n <a href={homeHref} className=\"flex min-w-0 items-center *:truncate\">\n {logo}\n </a>\n <div className=\"ms-auto flex items-center gap-1\">\n {search ? (\n <Button variant=\"ghost\" size=\"icon-md\" command=\"show-modal\" commandfor={`${id}-search`} aria-label=\"Search\">\n <Icon name=\"search\" />\n </Button>\n ) : null}\n {accountHref ? (\n <a href={accountHref} className={buttonVariants({ variant: \"ghost\", size: \"icon-md\" })} aria-label=\"Account\">\n <Icon name=\"user\" />\n </a>\n ) : null}\n {cart ? (\n <a href={cart.href} className={buttonVariants({ variant: \"ghost\", size: \"icon-md\" })} aria-label={cartLabel}>\n <Icon name=\"cart\" />\n {cart.count ? (\n <Badge size=\"sm\" className=\"absolute top-0 start-[calc(100%_-_var(--button-padding-x))] -translate-y-1/2 ring-(length:--bl-overlap-ring-width) ring-background\">\n {cart.count}\n </Badge>\n ) : null}\n </a>\n ) : null}\n </div>\n </div>\n </Container>\n </Section>\n <Section as=\"div\" paddingTop=\"none\" paddingBottom=\"none\" className=\"hidden border-b border-border py-2 md:flex\">\n <Container width=\"wide\">\n <NavigationMenu aria-label=\"Main\">\n <NavigationMenuList>\n {nav.map((item, i) => (\n <NavigationMenuItem key={item.label}>\n {item.items?.length ? (\n <>\n <NavigationMenuTrigger commandfor={`${id}-nav-${i}`}>{item.label}</NavigationMenuTrigger>\n <NavigationMenuContent id={`${id}-nav-${i}`}>\n <ul className=\"grid w-72\">\n {item.items.map((link) => (\n <li key={link.href}>\n <NavigationMenuLink href={link.href}>\n <div className=\"flex flex-col gap-(--bl-navigation-menu-link-description-gap)\">\n <div className=\"leading-none font-(--bl-navigation-menu-link-title-font-weight)\">{link.label}</div>\n {link.description ? <div className=\"line-clamp-2 text-muted-foreground\">{link.description}</div> : null}\n </div>\n </NavigationMenuLink>\n </li>\n ))}\n </ul>\n </NavigationMenuContent>\n </>\n ) : (\n <NavigationMenuLink href={item.href} className={navigationMenuTriggerStyle()}>\n {item.label}\n </NavigationMenuLink>\n )}\n </NavigationMenuItem>\n ))}\n </NavigationMenuList>\n </NavigationMenu>\n </Container>\n </Section>\n\n <SheetContent id={`${id}-menu`} side=\"left\" className=\"bg-sidebar text-sidebar-foreground\">\n <SheetHeader>\n <SheetTitle id={`${id}-menu-title`}>Menu</SheetTitle>\n </SheetHeader>\n <nav aria-label=\"Main\" className=\"px-(--bl-sheet-padding)\">\n <SidebarMenu>\n {nav.map((item) => (\n <SidebarMenuItem key={item.label}>\n <SidebarMenuButton render={<a href={item.href} />}>{item.label}</SidebarMenuButton>\n {item.items?.length ? (\n <SidebarMenuSub>\n {item.items.map((link) => (\n <SidebarMenuSubItem key={link.href}>\n <SidebarMenuSubButton render={<a href={link.href} />}>{link.label}</SidebarMenuSubButton>\n </SidebarMenuSubItem>\n ))}\n </SidebarMenuSub>\n ) : null}\n </SidebarMenuItem>\n ))}\n </SidebarMenu>\n </nav>\n </SheetContent>\n\n {search ? (\n <DialogContent id={`${id}-search`}>\n <DialogHeader>\n <DialogTitle id={`${id}-search-title`}>Search</DialogTitle>\n </DialogHeader>\n <form role=\"search\" action={search} method=\"get\" className=\"flex gap-2\">\n <Input type=\"search\" name=\"q\" aria-label=\"Search\" placeholder=\"Search…\" className=\"flex-1\" />\n <Button type=\"submit\">Search</Button>\n </form>\n </DialogContent>\n ) : null}\n </header>\n );\n}\n\nexport { HeaderStacked };\nexport type { HeaderStackedProps, HeaderLink, HeaderNavItem };\n",
23
+ "type": "registry:component",
24
+ "target": "components/header-stacked.tsx"
25
+ }
26
+ ],
27
+ "meta": {
28
+ "spec": "components/header/spec.md",
29
+ "figma": "2593:8976",
30
+ "status": "draft",
31
+ "block": true
32
+ },
33
+ "type": "registry:block"
34
+ }
@@ -0,0 +1,28 @@
1
+ {
2
+ "$schema": "https://ui.shadcn.com/schema/registry-item.json",
3
+ "name": "hero-centered",
4
+ "title": "Hero Centered",
5
+ "description": "Baseline block hero-centered (draft): a composed section to copy and own. Spec: components/sections/spec.md",
6
+ "registryDependencies": [
7
+ "@baseline/utils",
8
+ "@baseline/section",
9
+ "@baseline/container",
10
+ "@baseline/columns",
11
+ "@baseline/button"
12
+ ],
13
+ "files": [
14
+ {
15
+ "path": "src/blocks/hero-centered/hero-centered.tsx",
16
+ "content": "// baseline:hero-centered@0.1.0\n// Baseline block (spec: components/sections/spec.md; Figma Hero/Centered 2395:22791). A composed section to copy and\n// own: Section > Container (content) > Columns/1 with a centred display headline, a lead paragraph and two actions.\n// Spacing between the parts is Figma's: 20px from the headline to the paragraph and to the actions (spacing/5).\nimport { Section } from \"@/registry/ui/section/section\";\nimport { Container } from \"@/registry/ui/container/container\";\nimport { Columns } from \"@/registry/ui/columns/columns\";\nimport { buttonVariants } from \"@/registry/ui/button/button\";\n\ntype SectionAction = { label: string; href: string };\n\ntype HeroCenteredProps = {\n id?: string;\n title: string;\n description?: string;\n /** the first is the primary action, the rest outline */\n actions?: SectionAction[];\n};\n\nfunction HeroCentered({ id = \"hero\", title, description, actions = [] }: HeroCenteredProps) {\n return (\n <Section aria-labelledby={`${id}-title`}>\n <Container width=\"content\">\n <Columns columns={1}>\n <div className=\"flex flex-col items-center text-center\">\n <h1 id={`${id}-title`} className=\"display-sm text-balance\">\n {title}\n </h1>\n {description ? <p className=\"mt-5 text-base leading-7 text-pretty text-muted-foreground\">{description}</p> : null}\n {actions.length ? (\n <div className=\"mt-5 flex flex-wrap justify-center gap-2\">\n {actions.map((a, i) => (\n <a key={a.href} href={a.href} className={buttonVariants({ variant: i === 0 ? \"default\" : \"outline\" })}>\n {a.label}\n </a>\n ))}\n </div>\n ) : null}\n </div>\n </Columns>\n </Container>\n </Section>\n );\n}\n\nexport { HeroCentered };\nexport type { HeroCenteredProps, SectionAction };\n",
17
+ "type": "registry:component",
18
+ "target": "components/hero-centered.tsx"
19
+ }
20
+ ],
21
+ "meta": {
22
+ "spec": "components/sections/spec.md",
23
+ "figma": "2398:23021",
24
+ "status": "draft",
25
+ "block": true
26
+ },
27
+ "type": "registry:block"
28
+ }
@@ -0,0 +1,29 @@
1
+ {
2
+ "$schema": "https://ui.shadcn.com/schema/registry-item.json",
3
+ "name": "hero-split",
4
+ "title": "Hero Split",
5
+ "description": "Baseline block hero-split (draft): a composed section to copy and own. Spec: components/sections/spec.md",
6
+ "registryDependencies": [
7
+ "@baseline/utils",
8
+ "@baseline/section",
9
+ "@baseline/container",
10
+ "@baseline/columns",
11
+ "@baseline/button",
12
+ "@baseline/media"
13
+ ],
14
+ "files": [
15
+ {
16
+ "path": "src/blocks/hero-split/hero-split.tsx",
17
+ "content": "// baseline:hero-split@0.1.0\n// Baseline block (spec: components/sections/spec.md; Figma Hero/Split 2395:22722). A composed section to copy and own:\n// Section > Container (wide) > Columns/2 (1:1). The first column, centred vertically: an eyebrow over a display\n// headline (8px apart, spacing/2), a lead paragraph and two actions (20px, spacing/5); the second a square Media.\n// `invertOrder` puts the image above the text once the columns stack.\nimport { Section } from \"@/registry/ui/section/section\";\nimport { Container } from \"@/registry/ui/container/container\";\nimport { Columns } from \"@/registry/ui/columns/columns\";\nimport { buttonVariants } from \"@/registry/ui/button/button\";\nimport { Media, type MediaImage } from \"@/registry/ui/media/media\";\n\ntype SectionAction = { label: string; href: string };\n\ntype HeroSplitProps = {\n id?: string;\n eyebrow?: string;\n title: string;\n description?: string;\n /** the first is the primary action, the rest outline */\n actions?: SectionAction[];\n image: MediaImage;\n /** the image first on mobile (Figma's Invert Order) */\n invertOrder?: boolean;\n};\n\nfunction HeroSplit({ id = \"hero\", eyebrow, title, description, actions = [], image, invertOrder }: HeroSplitProps) {\n return (\n <Section aria-labelledby={`${id}-title`}>\n <Container width=\"wide\">\n <Columns columns={2} invertOrder={invertOrder}>\n <div className=\"flex flex-col justify-center\">\n <hgroup className=\"flex flex-col gap-2\">\n {eyebrow ? <p className=\"text-sm text-primary\">{eyebrow}</p> : null}\n <h1 id={`${id}-title`} className=\"display-sm text-balance\">\n {title}\n </h1>\n </hgroup>\n {description ? <p className=\"mt-5 text-base leading-7 text-pretty text-muted-foreground\">{description}</p> : null}\n {actions.length ? (\n <div className=\"mt-5 flex flex-wrap gap-2\">\n {actions.map((a, i) => (\n <a key={a.href} href={a.href} className={buttonVariants({ variant: i === 0 ? \"default\" : \"outline\" })}>\n {a.label}\n </a>\n ))}\n </div>\n ) : null}\n </div>\n <Media image={image} ratio={1} />\n </Columns>\n </Container>\n </Section>\n );\n}\n\nexport { HeroSplit };\nexport type { HeroSplitProps, SectionAction };\n",
18
+ "type": "registry:component",
19
+ "target": "components/hero-split.tsx"
20
+ }
21
+ ],
22
+ "meta": {
23
+ "spec": "components/sections/spec.md",
24
+ "figma": "2398:23021",
25
+ "status": "draft",
26
+ "block": true
27
+ },
28
+ "type": "registry:block"
29
+ }
@@ -0,0 +1,29 @@
1
+ {
2
+ "$schema": "https://ui.shadcn.com/schema/registry-item.json",
3
+ "name": "hover-card",
4
+ "title": "Hover-card",
5
+ "description": "Baseline hover-card (ready). Spec: components/hover-card/spec.md. Not shadcn's API (ADR-0033): no HoverCard root; give HoverCardContent an id and point a <HoverCardTrigger interestfor=\"<id>\"> at it. Needs @baseline/interest",
6
+ "dependencies": [
7
+ "@base-ui/react",
8
+ "class-variance-authority"
9
+ ],
10
+ "registryDependencies": [
11
+ "@baseline/utils",
12
+ "@baseline/interest"
13
+ ],
14
+ "files": [
15
+ {
16
+ "path": "src/ui/hover-card/hover-card.tsx",
17
+ "content": "// baseline:hover-card@0.1.0\n// Fork of shadcn/ui Hover Card (base-ui flavour, source read 2026-10-02). Spec: components/hover-card/spec.md.\n// Geometry and the border tint come from Figma through the hover-card/* tokens (ADR-0017); the look is Popover's.\n//\n// Ladder step 2 (ADR-0006, ADR-0030): a native <div popover=\"hint\"> shown by an interest invoker (`interestfor`:\n// hover and keyboard focus, after the trigger's delays) and placed by CSS anchor positioning, not Base UI's\n// PreviewCard. interestfor is Chromium only (2026-10); InterestFallback (lib/interest, shared with Tooltip) gives other\n// browsers the same behaviour and does nothing where interestfor exists (user, 2026-10-06).\n//\n// SERVER COMPONENTS with explicit ids (ADR-0033, as Popover and Tooltip): no HoverCard root. The trigger is an unstyled\n// <a>, as upstream's PreviewCard trigger; your own element through `render` (useRender, which runs on the server).\n// <HoverCardTrigger interestfor=\"nextjs\" href=\"https://nextjs.org\">@nextjs</HoverCardTrigger>\n// <HoverCardContent id=\"nextjs\">The React Framework…</HoverCardContent>\nimport * as React from \"react\";\nimport { mergeProps } from \"@base-ui/react/merge-props\";\nimport { useRender } from \"@base-ui/react/use-render\";\nimport { cva } from \"class-variance-authority\";\nimport { cn } from \"@/lib/utils\";\nimport { InterestFallback } from \"@/lib/interest\";\n\n// Interest invokers (Chromium 142) are not in @types/react 19 yet; React passes the lowercase attribute through.\ndeclare module \"react\" {\n interface ButtonHTMLAttributes<T> {\n interestfor?: string;\n }\n interface AnchorHTMLAttributes<T> {\n interestfor?: string;\n }\n}\n\ntype Side = \"top\" | \"bottom\" | \"left\" | \"right\" | \"inline-start\" | \"inline-end\";\ntype Align = \"start\" | \"center\" | \"end\";\n\nconst anchorName = (id: string) => `--${id.replace(/[^\\w-]/g, \"_\")}`;\n\n// as Popover: position-area for each side and alignment, the margin facing the trigger, the flip, the align margin\nconst AREA: Record<Side, Record<Align, string>> = {\n bottom: { start: \"block-end span-inline-end\", center: \"block-end\", end: \"block-end span-inline-start\" },\n top: { start: \"block-start span-inline-end\", center: \"block-start\", end: \"block-start span-inline-start\" },\n \"inline-end\": { start: \"inline-end span-block-end\", center: \"inline-end\", end: \"inline-end span-block-start\" },\n \"inline-start\": { start: \"inline-start span-block-end\", center: \"inline-start\", end: \"inline-start span-block-start\" },\n right: { start: \"right span-bottom\", center: \"right\", end: \"right span-top\" },\n left: { start: \"left span-bottom\", center: \"left\", end: \"left span-top\" },\n};\nconst SIDE_MARGIN: Record<Side, keyof React.CSSProperties> = {\n bottom: \"marginBlockStart\",\n top: \"marginBlockEnd\",\n \"inline-end\": \"marginInlineStart\",\n \"inline-start\": \"marginInlineEnd\",\n right: \"marginLeft\",\n left: \"marginRight\",\n};\nconst FLIP: Record<Side, string> = {\n bottom: \"flip-block\",\n top: \"flip-block\",\n \"inline-end\": \"flip-inline\",\n \"inline-start\": \"flip-inline\",\n right: \"flip-inline\",\n left: \"flip-inline\",\n};\nconst ALIGN_MARGIN: Record<Side, Record<\"start\" | \"end\", keyof React.CSSProperties>> = {\n bottom: { start: \"marginInlineStart\", end: \"marginInlineEnd\" },\n top: { start: \"marginInlineStart\", end: \"marginInlineEnd\" },\n \"inline-end\": { start: \"marginBlockStart\", end: \"marginBlockEnd\" },\n \"inline-start\": { start: \"marginBlockStart\", end: \"marginBlockEnd\" },\n right: { start: \"marginTop\", end: \"marginBottom\" },\n left: { start: \"marginTop\", end: \"marginBottom\" },\n};\n\n// Closed it is hidden through a variant (not-open:hidden), which a display class passed in className cannot undo\n// (tailwind-merge drops a plain `hidden` against `flex`), so `display` can transition with it (transition-discrete). Upstream's 100ms fade, zoom from 95%\n// and slide in from the trigger's side, as transitions from @starting-style; none under reduced motion.\nconst contentVariants = cva(\n [\n \"group/hover-card-content inset-auto m-0 not-open:hidden w-(--bl-hover-card-width) max-w-[calc(100vw-1rem)] overflow-visible\",\n \"rounded-(--bl-hover-card-radius) border-0 bg-popover p-(--bl-hover-card-padding) text-sm text-popover-foreground shadow-md outline-none\",\n \"ring-(length:--bl-hover-card-border-width) ring-(--bl-hover-card-border)\",\n \"scale-95 opacity-0 open:translate-0 open:scale-100 open:opacity-100 starting:open:scale-95 starting:open:opacity-0\",\n \"transition-[opacity,scale,translate,display,overlay] transition-discrete duration-100 motion-reduce:transition-none\",\n ],\n {\n variants: {\n side: {\n bottom: \"origin-top -translate-y-2 starting:open:-translate-y-2\",\n top: \"origin-bottom translate-y-2 starting:open:translate-y-2\",\n right: \"origin-left -translate-x-2 starting:open:-translate-x-2\",\n left: \"origin-right translate-x-2 starting:open:translate-x-2\",\n \"inline-end\": \"origin-left -translate-x-2 starting:open:-translate-x-2 rtl:origin-right rtl:translate-x-2 rtl:starting:open:translate-x-2\",\n \"inline-start\": \"origin-right translate-x-2 starting:open:translate-x-2 rtl:origin-left rtl:-translate-x-2 rtl:starting:open:-translate-x-2\",\n },\n },\n defaultVariants: { side: \"bottom\" },\n },\n);\n\ntype HoverCardTriggerProps = Omit<useRender.ComponentProps<\"a\">, \"interestfor\"> & {\n /** Required: the HoverCardContent's id. */\n interestfor: string;\n /** ms before it opens (default 600, Base UI's) */\n delay?: number;\n /** ms before it closes (default 300, Base UI's) */\n closeDelay?: number;\n};\n\n/**\n * Shows the card on hover and keyboard focus after `delay`, hides it `closeDelay` after interest is lost, and is its\n * anchor. Unstyled, as upstream's: an <a>, or your own element through `render` (`render={<Button variant=\"link\" />}`).\n * It has to end up an <a> or a <button>, the elements interestfor works on.\n */\nfunction HoverCardTrigger({ interestfor, delay = 600, closeDelay = 300, render, ...props }: HoverCardTriggerProps) {\n return useRender({\n defaultTagName: \"a\",\n render,\n props: mergeProps<\"a\">(\n {\n interestfor,\n // interest-delay-start / -end for the native path (not in csstype yet), data-interest-* for InterestFallback\n style: {\n anchorName: anchorName(interestfor),\n ...({ interestDelayStart: `${delay}ms`, interestDelayEnd: `${closeDelay}ms` } as React.CSSProperties),\n },\n ...({ \"data-interest-delay\": String(delay), \"data-interest-close-delay\": String(closeDelay) } as Record<string, string>),\n },\n props,\n ),\n state: { slot: \"hover-card-trigger\" },\n });\n}\n\ntype HoverCardContentProps = Omit<React.ComponentProps<\"div\">, \"id\"> & {\n /** Required: the id the trigger points at. */\n id: string;\n side?: Side;\n align?: Align;\n /** px between the trigger and the card (default 4) */\n sideOffset?: number;\n /** px along the side, away from the aligned edge (start / end only; default 4, upstream's) */\n alignOffset?: number;\n};\n\n/** The card: <div popover=\"hint\">, below the trigger by default. */\nfunction HoverCardContent({\n id,\n side = \"bottom\",\n align = \"center\",\n sideOffset = 4,\n alignOffset = 4,\n className,\n style,\n children,\n ...props\n}: HoverCardContentProps) {\n const placement: React.CSSProperties = {\n positionAnchor: anchorName(id),\n positionArea: AREA[side][align],\n positionTryFallbacks: FLIP[side],\n [SIDE_MARGIN[side]]: sideOffset,\n ...(align !== \"center\" && alignOffset ? { [ALIGN_MARGIN[side][align]]: alignOffset } : {}),\n };\n return (\n <div\n id={id}\n popover=\"hint\"\n data-slot=\"hover-card-content\"\n data-side={side}\n data-align={align}\n className={cn(contentVariants({ side }), className)}\n style={{ ...placement, ...style }}\n {...props}\n >\n {children}\n <InterestFallback />\n </div>\n );\n}\n\nexport { HoverCardContent, HoverCardTrigger };\nexport type { HoverCardContentProps, HoverCardTriggerProps };\n",
18
+ "type": "registry:ui",
19
+ "target": "components/ui/hover-card.tsx"
20
+ }
21
+ ],
22
+ "meta": {
23
+ "spec": "components/hover-card/spec.md",
24
+ "figma": "1942:39787",
25
+ "upstream": "shadcn/ui hover-card (base-ui flavour, source read from ui.shadcn.com 2026-10-02)",
26
+ "status": "ready"
27
+ },
28
+ "type": "registry:ui"
29
+ }
package/r/icon.json ADDED
@@ -0,0 +1,30 @@
1
+ {
2
+ "$schema": "https://ui.shadcn.com/schema/registry-item.json",
3
+ "name": "icon",
4
+ "title": "Icon",
5
+ "description": "Baseline icon (ready). Spec: components/icon/spec.md",
6
+ "registryDependencies": [
7
+ "@baseline/utils"
8
+ ],
9
+ "files": [
10
+ {
11
+ "path": "src/ui/icon/icon.tsx",
12
+ "content": "// baseline:icon@0.1.0\n// System icon (PLAN §4.9). Renders an Iconify icon as a CSS mask through @iconify/tailwind4: no runtime JS, no SVG\n// in the DOM, currentColor. The `name` prop is a Baseline system icon (tokens/icons/vocabulary.json) resolved to the\n// ACTIVE icon set at build time (icon.map.ts is generated by tokens/icons.mjs). Content icons, the designer's\n// free pick from the active set, pass their own class instead:\n// <Icon name=\"chevron-down\" /> system icon\n// <Icon className=\"icon-[material-symbols--rocket-rounded]\" /> content icon (the literal class is what Tailwind scans)\n// Server-safe: no hooks, no directive. Decorative by default (aria-hidden); pass `aria-label` + role=\"img\" for meaning.\nimport * as React from \"react\";\nimport { cn } from \"@/lib/utils\";\n// relative, not @/registry/ui/icon/icon.map: shadcn rewrites that second file of the item to @/components/icon/… (a\n// path that does not exist in the installed project); both files land side by side in components/ui\nimport { systemIcons, type SystemIcon } from \"./icon.map\";\n\ntype IconProps = React.ComponentProps<\"span\"> & {\n /** Baseline system icon name; omit for a content icon and pass its `icon-[set--name]` class. */\n name?: SystemIcon;\n};\n\nfunction Icon({ name, className, \"aria-label\": ariaLabel, ...props }: IconProps) {\n return (\n <span\n data-slot=\"icon\"\n data-icon={name}\n role={ariaLabel ? \"img\" : undefined}\n aria-hidden={ariaLabel ? undefined : true}\n aria-label={ariaLabel}\n className={cn(\"inline-block size-4 shrink-0 align-middle\", name ? systemIcons[name] : undefined, className)}\n {...props}\n />\n );\n}\n\nexport { Icon };\nexport type { IconProps, SystemIcon };\n",
13
+ "type": "registry:ui",
14
+ "target": "components/ui/icon.tsx"
15
+ },
16
+ {
17
+ "path": "src/ui/icon/icon.map.ts",
18
+ "content": "// Generated by tokens/icons.mjs (baseline-tokens icons) from tokens/icons/vocabulary.json (active set: material-symbols). Do not edit.\n// Baseline system icon -> Iconify CSS class (mask, no runtime JS). Content icons pass their own class:\n// <Icon className=\"icon-[material-symbols--rocket]\" />\nexport const ICON_SET = \"material-symbols\" as const;\n// brand-* icons are Simple Icons and never change with the set (they need @iconify-json/simple-icons)\nexport const systemIcons = {\n \"arrow-down\": \"icon-[material-symbols--arrow-downward-rounded]\",\n \"arrow-left\": \"icon-[material-symbols--arrow-back-rounded]\",\n \"arrow-right\": \"icon-[material-symbols--arrow-forward-rounded]\",\n \"arrow-up\": \"icon-[material-symbols--arrow-upward-rounded]\",\n \"arrow-up-down\": \"icon-[material-symbols--swap-vert-rounded]\",\n \"calendar\": \"icon-[material-symbols--calendar-today-rounded]\",\n \"cart\": \"icon-[material-symbols--shopping-cart-rounded]\",\n \"check\": \"icon-[material-symbols--check-rounded]\",\n \"chevron-down\": \"icon-[material-symbols--keyboard-arrow-down-rounded]\",\n \"chevron-left\": \"icon-[material-symbols--keyboard-arrow-left-rounded]\",\n \"chevron-right\": \"icon-[material-symbols--keyboard-arrow-right-rounded]\",\n \"chevron-up\": \"icon-[material-symbols--keyboard-arrow-up-rounded]\",\n \"chevrons-left\": \"icon-[material-symbols--keyboard-double-arrow-left-rounded]\",\n \"chevrons-right\": \"icon-[material-symbols--keyboard-double-arrow-right-rounded]\",\n \"chevrons-up-down\": \"icon-[material-symbols--unfold-more-rounded]\",\n \"circle\": \"icon-[material-symbols--circle-rounded]\",\n \"clock\": \"icon-[material-symbols--schedule-rounded]\",\n \"close\": \"icon-[material-symbols--close-rounded]\",\n \"close-circle\": \"icon-[material-symbols--cancel-rounded]\",\n \"copy\": \"icon-[material-symbols--content-copy-rounded]\",\n \"download\": \"icon-[material-symbols--download-rounded]\",\n \"edit\": \"icon-[material-symbols--edit-rounded]\",\n \"error\": \"icon-[material-symbols--error-rounded]\",\n \"external-link\": \"icon-[material-symbols--open-in-new-rounded]\",\n \"eye\": \"icon-[material-symbols--visibility-rounded]\",\n \"eye-off\": \"icon-[material-symbols--visibility-off-rounded]\",\n \"file\": \"icon-[material-symbols--description-rounded]\",\n \"filter\": \"icon-[material-symbols--filter-list-rounded]\",\n \"grip-vertical\": \"icon-[material-symbols--drag-indicator-rounded]\",\n \"home\": \"icon-[material-symbols--home-rounded]\",\n \"image\": \"icon-[material-symbols--image-rounded]\",\n \"info\": \"icon-[material-symbols--info-rounded]\",\n \"link\": \"icon-[material-symbols--link-rounded]\",\n \"loader\": \"icon-[material-symbols--progress-activity-rounded]\",\n \"lock\": \"icon-[material-symbols--lock-rounded]\",\n \"mail\": \"icon-[material-symbols--mail-rounded]\",\n \"menu\": \"icon-[material-symbols--menu-rounded]\",\n \"minus\": \"icon-[material-symbols--remove-rounded]\",\n \"more-horizontal\": \"icon-[material-symbols--more-horiz-rounded]\",\n \"more-vertical\": \"icon-[material-symbols--more-vert-rounded]\",\n \"pause\": \"icon-[material-symbols--pause-rounded]\",\n \"phone\": \"icon-[material-symbols--call-rounded]\",\n \"play\": \"icon-[material-symbols--play-arrow-rounded]\",\n \"plus\": \"icon-[material-symbols--add-rounded]\",\n \"refresh\": \"icon-[material-symbols--refresh-rounded]\",\n \"search\": \"icon-[material-symbols--search-rounded]\",\n \"settings\": \"icon-[material-symbols--settings-rounded]\",\n \"share\": \"icon-[material-symbols--share-rounded]\",\n \"sidebar\": \"icon-[material-symbols--view-sidebar-rounded]\",\n \"star\": \"icon-[material-symbols--star-rounded]\",\n \"success\": \"icon-[material-symbols--check-circle-rounded]\",\n \"trash\": \"icon-[material-symbols--delete-rounded]\",\n \"upload\": \"icon-[material-symbols--upload-rounded]\",\n \"user\": \"icon-[material-symbols--person-rounded]\",\n \"warning\": \"icon-[material-symbols--warning-rounded]\",\n \"brand-amazon\": \"icon-[simple-icons--amazon]\",\n \"brand-americanexpress\": \"icon-[simple-icons--americanexpress]\",\n \"brand-apple\": \"icon-[simple-icons--apple]\",\n \"brand-applepay\": \"icon-[simple-icons--applepay]\",\n \"brand-bluesky\": \"icon-[simple-icons--bluesky]\",\n \"brand-discord\": \"icon-[simple-icons--discord]\",\n \"brand-facebook\": \"icon-[simple-icons--facebook]\",\n \"brand-github\": \"icon-[simple-icons--github]\",\n \"brand-google\": \"icon-[simple-icons--google]\",\n \"brand-googlepay\": \"icon-[simple-icons--googlepay]\",\n \"brand-instagram\": \"icon-[simple-icons--instagram]\",\n \"brand-linkedin\": \"icon-[simple-icons--linkedin]\",\n \"brand-mastercard\": \"icon-[simple-icons--mastercard]\",\n \"brand-paypal\": \"icon-[simple-icons--paypal]\",\n \"brand-pinterest\": \"icon-[simple-icons--pinterest]\",\n \"brand-reddit\": \"icon-[simple-icons--reddit]\",\n \"brand-shopify\": \"icon-[simple-icons--shopify]\",\n \"brand-snapchat\": \"icon-[simple-icons--snapchat]\",\n \"brand-spotify\": \"icon-[simple-icons--spotify]\",\n \"brand-telegram\": \"icon-[simple-icons--telegram]\",\n \"brand-threads\": \"icon-[simple-icons--threads]\",\n \"brand-tiktok\": \"icon-[simple-icons--tiktok]\",\n \"brand-tripadvisor\": \"icon-[simple-icons--tripadvisor]\",\n \"brand-vimeo\": \"icon-[simple-icons--vimeo]\",\n \"brand-visa\": \"icon-[simple-icons--visa]\",\n \"brand-whatsapp\": \"icon-[simple-icons--whatsapp]\",\n \"brand-wordpress\": \"icon-[simple-icons--wordpress]\",\n \"brand-x\": \"icon-[simple-icons--x]\",\n \"brand-yelp\": \"icon-[simple-icons--yelp]\",\n \"brand-youtube\": \"icon-[simple-icons--youtube]\",\n} as const;\nexport type SystemIcon = keyof typeof systemIcons;\n",
19
+ "type": "registry:ui",
20
+ "target": "components/ui/icon.map.ts"
21
+ }
22
+ ],
23
+ "meta": {
24
+ "spec": "components/icon/spec.md",
25
+ "figma": "1839:2806",
26
+ "upstream": "shadcn/ui icon libraries (iconLibrary mapping) + @iconify/tailwind4",
27
+ "status": "ready"
28
+ },
29
+ "type": "registry:ui"
30
+ }
@@ -0,0 +1,30 @@
1
+ {
2
+ "$schema": "https://ui.shadcn.com/schema/registry-item.json",
3
+ "name": "input-group",
4
+ "title": "Input-group",
5
+ "description": "Baseline input-group (draft). Spec: components/input-group/spec.md",
6
+ "dependencies": [
7
+ "class-variance-authority"
8
+ ],
9
+ "registryDependencies": [
10
+ "@baseline/utils",
11
+ "@baseline/button",
12
+ "@baseline/input",
13
+ "@baseline/textarea"
14
+ ],
15
+ "files": [
16
+ {
17
+ "path": "src/ui/input-group/input-group.tsx",
18
+ "content": "\"use client\";\n// baseline:input-group@0.1.0\n// Fork of shadcn/ui InputGroup (base-ui flavour, source read 2026-09-30). Spec: components/input-group/spec.md.\n// Geometry comes from Figma through the input-group/* and control/addon/* tokens (ADR-0017).\n//\n// The group owns the box — border, radius, height, focus ring — and the control inside goes borderless. Things\n// that sit BESIDE a control rather than inside it are Button Group or plain layout, not this.\n//\n// 'use client' is on the file rather than on the addon leaf, which is what the spec originally assumed. A\n// registry item publishes one source file, so there is no smaller leaf to carve out: the addon's\n// click-to-focus handler is an event handler, and an event handler makes its module client. Everything else\n// here would be server-safe on its own.\nimport * as React from \"react\";\nimport { cva, type VariantProps } from \"class-variance-authority\";\nimport { cn } from \"@/lib/utils\";\nimport { Button } from \"@/registry/ui/button/button\";\nimport { Input } from \"@/registry/ui/input/input\";\nimport { Textarea } from \"@/registry/ui/textarea/textarea\";\n\n/**\n * The box. It carries no state props of its own: `:has()` and `:focus-within` let it react to the control\n * inside, which is why a disabled or invalid control styles the whole group (spec: States).\n */\nfunction InputGroup({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n <div\n data-slot=\"input-group\"\n role=\"group\"\n className={cn(\n [\n \"group/input-group relative flex w-full min-w-0 items-center outline-none transition-colors\",\n \"h-(--bl-input-group-md-height) rounded-(--bl-input-group-md-radius)\",\n \"border-(length:--bl-input-group-md-border-width) border-input\",\n // the group reacts to the control rather than taking props: upstream's has-[…] model\n \"has-disabled:opacity-50\",\n \"has-[[data-slot=input-group-control]:focus-visible]:border-primary\",\n \"has-[[data-slot=input-group-control]:focus-visible]:ring-(length:--bl-control-ring-width)\",\n \"has-[[data-slot=input-group-control]:focus-visible]:ring-ring\",\n \"has-[[data-slot][aria-invalid=true]]:border-destructive\",\n \"has-[[data-slot][aria-invalid=true]]:ring-(length:--bl-control-ring-width)\",\n \"has-[[data-slot][aria-invalid=true]]:ring-(color:--ring-invalid)\",\n // a block addon or a textarea turns the row into a column and frees the height: this is Figma's\n // Layout=textarea, expressed as a consequence of the content rather than as a prop\n \"has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col\",\n \"has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col\",\n \"has-[>textarea]:h-auto\",\n // the control gives up the padding on whichever side an addon takes over\n \"has-[>[data-align=inline-start]]:[&>input]:ps-(--bl-input-group-md-padding-y)\",\n \"has-[>[data-align=inline-end]]:[&>input]:pe-(--bl-input-group-md-padding-y)\",\n ].join(\" \"),\n className,\n )}\n {...props}\n />\n );\n}\n\nconst inputGroupAddonVariants = cva(\n [\n \"flex h-auto cursor-text items-center justify-center select-none\",\n \"gap-(--bl-control-addon-gap) py-(--bl-input-group-md-padding-y)\",\n \"text-(length:--bl-input-group-md-font-size) leading-(--bl-input-group-md-line-height)\",\n \"font-medium text-muted-foreground\",\n \"group-data-[disabled=true]/input-group:opacity-50\",\n \"[&>svg:not([class*='size-'])]:size-(--bl-control-addon-icon-size)\",\n \"[&>[data-slot=icon]]:size-(--bl-control-addon-icon-size)\",\n ].join(\" \"),\n {\n variants: {\n align: {\n // The negative margin is upstream's optical correction, kept deliberately (spec: Addons): a chip\n // inside a padded box otherwise reads as inset from the text above it. Figma does not draw it.\n \"inline-start\": \"order-first ps-(--bl-input-group-md-padding-x) has-[>button]:-ms-[0.3rem]\",\n \"inline-end\": \"order-last pe-(--bl-input-group-md-padding-x) has-[>button]:-me-[0.3rem]\",\n \"block-start\":\n \"order-first w-full justify-start px-(--bl-input-group-md-padding-x) pt-(--bl-control-addon-gap)\",\n \"block-end\":\n \"order-last w-full justify-start px-(--bl-input-group-md-padding-x) pb-(--bl-control-addon-gap)\",\n },\n },\n defaultVariants: { align: \"inline-start\" },\n },\n);\n\n/**\n * A slot inside the box: a prefix, a suffix, an icon, a unit, a small button. `align` is on the addon, not the\n * group — `block-start`/`block-end` are what Figma draws as `Layout=textarea`.\n *\n * Clicking it focuses the control, which is the one piece of JavaScript in this component. A click on a button\n * inside the addon is left alone, so an addon button still works as a button.\n */\nfunction InputGroupAddon({\n className,\n align = \"inline-start\",\n onClick,\n ...props\n}: React.ComponentProps<\"div\"> & VariantProps<typeof inputGroupAddonVariants>) {\n return (\n <div\n role=\"group\"\n data-slot=\"input-group-addon\"\n data-align={align}\n className={cn(inputGroupAddonVariants({ align }), className)}\n onClick={(event) => {\n onClick?.(event);\n if ((event.target as HTMLElement).closest(\"button\")) return;\n event.currentTarget.parentElement\n ?.querySelector<HTMLElement>(\"[data-slot=input-group-control]\")\n ?.focus();\n }}\n {...props}\n />\n );\n}\n\n/**\n * An addon chip. A thin pass-through to Button rather than a re-declaration of its sizes: upstream redefines\n * the height, radius and padding inline, which duplicates values Button already owns. Figma draws this as a\n * real Button instance at `secondary` `xs` for the same reason, so the chip moves when Button's tokens move.\n *\n * Upstream defaults it to `ghost`; Baseline follows Figma and defaults to `secondary`.\n */\nfunction InputGroupButton({\n className,\n type = \"button\",\n variant = \"secondary\",\n size = \"xs\",\n ...props\n}: React.ComponentProps<typeof Button>) {\n return (\n <Button\n type={type}\n data-slot=\"input-group-button\"\n variant={variant}\n size={size}\n className={cn(\"shadow-none\", className)}\n {...props}\n />\n );\n}\n\n/** A muted string inside the box — a unit, a prefix like `https://`. */\nfunction InputGroupText({ className, ...props }: React.ComponentProps<\"span\">) {\n return (\n <span\n data-slot=\"input-group-text\"\n className={cn(\n \"flex items-center gap-(--bl-control-addon-gap) text-muted-foreground\",\n \"text-(length:--bl-input-group-md-font-size) leading-(--bl-input-group-md-line-height)\",\n \"[&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-(--bl-control-addon-icon-size)\",\n \"[&_[data-slot=icon]]:size-(--bl-control-addon-icon-size)\",\n className,\n )}\n {...props}\n />\n );\n}\n\n/** Input, stripped: the group draws the border and the ring, so the control must draw neither. */\nfunction InputGroupInput({ className, ...props }: React.ComponentProps<typeof Input>) {\n return (\n <Input\n data-slot=\"input-group-control\"\n className={cn(\n \"h-full flex-1 rounded-none border-0 bg-transparent\",\n \"focus-visible:border-0 focus-visible:ring-0\",\n \"aria-invalid:border-0 aria-invalid:ring-0 aria-invalid:text-foreground\",\n \"disabled:bg-transparent disabled:opacity-100\",\n className,\n )}\n {...props}\n />\n );\n}\n\n/** Textarea, stripped, for the block-addon layout Figma draws as `Layout=textarea`. */\nfunction InputGroupTextarea({ className, ...props }: React.ComponentProps<typeof Textarea>) {\n return (\n <Textarea\n data-slot=\"input-group-control\"\n className={cn(\n \"flex-1 resize-none rounded-none border-0 bg-transparent\",\n \"py-(--bl-input-group-md-padding-y)\",\n \"focus-visible:border-0 focus-visible:ring-0\",\n \"aria-invalid:border-0 aria-invalid:ring-0 aria-invalid:text-foreground\",\n \"disabled:bg-transparent disabled:opacity-100\",\n className,\n )}\n {...props}\n />\n );\n}\n\nexport {\n InputGroup,\n InputGroupAddon,\n InputGroupButton,\n InputGroupText,\n InputGroupInput,\n InputGroupTextarea,\n};\n",
19
+ "type": "registry:ui",
20
+ "target": "components/ui/input-group.tsx"
21
+ }
22
+ ],
23
+ "meta": {
24
+ "spec": "components/input-group/spec.md",
25
+ "figma": "1773:47",
26
+ "upstream": "shadcn/ui input-group (base-ui flavour, source read from ui.shadcn.com 2026-09-25)",
27
+ "status": "draft"
28
+ },
29
+ "type": "registry:ui"
30
+ }
@@ -0,0 +1,28 @@
1
+ {
2
+ "$schema": "https://ui.shadcn.com/schema/registry-item.json",
3
+ "name": "input-otp",
4
+ "title": "Input-otp",
5
+ "description": "Baseline input-otp (draft). Spec: components/input-otp/spec.md",
6
+ "dependencies": [
7
+ "input-otp"
8
+ ],
9
+ "registryDependencies": [
10
+ "@baseline/utils",
11
+ "@baseline/icon"
12
+ ],
13
+ "files": [
14
+ {
15
+ "path": "src/ui/input-otp/input-otp.tsx",
16
+ "content": "\"use client\";\n// baseline:input-otp@0.1.0\n// Fork of shadcn/ui InputOTP (base-ui flavour, source read 2026-09-30). Spec: components/input-otp/spec.md.\n// Geometry comes from Figma through the input-otp/* tokens (ADR-0017).\n//\n// React only (user decision, 2026-09-30). ADR-0030's native-first test does not apply the way it does to\n// Checkbox or Radio: this is not \"a native element the library replaced\", it is one real <input> with a row of\n// boxes PAINTED over it. A row of separate inputs cannot do paste, backspace across boundaries, or the phone's\n// own one-time-code autofill without a lot of JavaScript and a lot of extra elements — which is the same\n// conclusion the `input-otp` package reached, and why Baseline takes the package rather than rebuilding it.\n//\n// It is the only B2 component with a third-party dependency.\nimport * as React from \"react\";\nimport { OTPInput, OTPInputContext } from \"input-otp\";\nimport { cn } from \"@/lib/utils\";\nimport { Icon } from \"@/registry/ui/icon/icon\";\n\n/**\n * InputOTP — one input wearing a row of boxes. `maxLength` is how many characters; the slots are indexed\n * against it. Set `autoComplete=\"one-time-code\"` for the SMS autofill on iOS and Android, and `pattern` to\n * restrict what is accepted (digits only is the common case).\n */\nfunction InputOTP({\n className,\n containerClassName,\n ...props\n}: React.ComponentProps<typeof OTPInput> & { containerClassName?: string }) {\n return (\n <OTPInput\n data-slot=\"input-otp\"\n containerClassName={cn(\n \"flex items-center gap-(--bl-input-otp-gap) has-disabled:opacity-50\",\n containerClassName,\n )}\n spellCheck={false}\n className={cn(\"disabled:cursor-not-allowed\", className)}\n {...props}\n />\n );\n}\n\n/** A run of slots. Groups are what the separator sits between. */\nfunction InputOTPGroup({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n <div\n data-slot=\"input-otp-group\"\n className={cn(\"flex items-center rounded-(--bl-input-otp-md-radius)\", className)}\n {...props}\n />\n );\n}\n\n/**\n * One painted box. Presentational and deliberately not focusable — the single real input behind the row takes\n * every keystroke, which is the whole point of the component.\n */\nfunction InputOTPSlot({\n index,\n className,\n ...props\n}: React.ComponentProps<\"div\"> & { index: number }) {\n const context = React.useContext(OTPInputContext);\n const { char, isActive } = context?.slots[index] ?? {};\n\n return (\n <div\n data-slot=\"input-otp-slot\"\n data-active={isActive}\n className={cn(\n [\n \"relative flex items-center justify-center outline-none transition-all\",\n // square, so one token drives both axes\n \"size-(--bl-input-otp-md-size)\",\n \"text-(length:--bl-input-otp-md-font-size) leading-(--bl-input-otp-md-line-height)\",\n // the boxes share edges: only the first slot draws a left border, and the run is rounded at its ends\n \"border-y-(length:--bl-input-otp-md-border-width) border-r-(length:--bl-input-otp-md-border-width)\",\n \"first:border-l-(length:--bl-input-otp-md-border-width) border-input\",\n \"first:rounded-s-(--bl-input-otp-md-radius) last:rounded-e-(--bl-input-otp-md-radius)\",\n // the active slot lifts above its neighbours so its ring is not clipped by the next border\n \"data-[active=true]:z-10 data-[active=true]:border-primary\",\n \"data-[active=true]:ring-(length:--bl-control-ring-width) data-[active=true]:ring-ring\",\n \"aria-invalid:border-destructive\",\n \"data-[active=true]:aria-invalid:border-destructive\",\n \"data-[active=true]:aria-invalid:ring-(color:--ring-invalid)\",\n ].join(\" \"),\n className,\n )}\n {...props}\n >\n {char}\n </div>\n );\n}\n\n/**\n * The separator between groups. `role=\"separator\"` and no text, because it carries no meaning a screen reader\n * needs — the code is read from the real input.\n */\nfunction InputOTPSeparator({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n <div\n data-slot=\"input-otp-separator\"\n role=\"separator\"\n className={cn(\"flex items-center text-muted-foreground\", className)}\n {...props}\n >\n <Icon name=\"minus\" className=\"size-(--bl-input-otp-separator-icon-size)\" />\n </div>\n );\n}\n\nexport { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator };\n",
17
+ "type": "registry:ui",
18
+ "target": "components/ui/input-otp.tsx"
19
+ }
20
+ ],
21
+ "meta": {
22
+ "spec": "components/input-otp/spec.md",
23
+ "figma": "493:26",
24
+ "upstream": "shadcn/ui input-otp (base-ui flavour, source read from ui.shadcn.com 2026-09-25)",
25
+ "status": "draft"
26
+ },
27
+ "type": "registry:ui"
28
+ }
package/r/input.json ADDED
@@ -0,0 +1,27 @@
1
+ {
2
+ "$schema": "https://ui.shadcn.com/schema/registry-item.json",
3
+ "name": "input",
4
+ "title": "Input",
5
+ "description": "Baseline input (ready). Spec: components/input/spec.md",
6
+ "dependencies": [
7
+ "@base-ui/react"
8
+ ],
9
+ "registryDependencies": [
10
+ "@baseline/utils"
11
+ ],
12
+ "files": [
13
+ {
14
+ "path": "src/ui/input/input.tsx",
15
+ "content": "// baseline:input@0.1.0\n// Fork of shadcn/ui Input (base-ui flavour, ADR-0005). Spec: components/input/spec.md.\n// Geometry comes from Figma through the input/* component tokens (ADR-0017): a designer's change to the\n// control height, padding, radius, border or text size reaches this file with `npm run tokens`.\nimport * as React from \"react\";\nimport { Input as InputPrimitive } from \"@base-ui/react/input\";\nimport { cn } from \"@/lib/utils\";\n\n/**\n * Input — the single-line text control. It owns its box and nothing else: no label, no description,\n * no error text. Those belong to Field, which composes Input.\n *\n * Base UI's Input reads Field context, so inside a Field it gets data-disabled, data-invalid,\n * data-filled and data-focused for free. This wrapper carries no directive; Base UI's leaf is the\n * client component.\n */\nfunction Input({ className, type, ...props }: React.ComponentProps<typeof InputPrimitive>) {\n return (\n <InputPrimitive\n type={type}\n data-slot=\"input\"\n className={cn(\n [\n // content-center is align-content on a block container: it vertically centres the in-flow content\n // without touching display. type=file needs it, because the browser lays the file button and the\n // \"no file chosen\" label out as in-flow content that otherwise sits at the top of a fixed-height box.\n \"w-full min-w-0 content-center bg-transparent text-foreground outline-none transition-colors\",\n // box: every value is an input/* variable, never a Tailwind step (ADR-0017)\n \"h-(--bl-input-md-height) rounded-(--bl-input-md-radius)\",\n \"px-(--bl-input-md-padding-x) py-(--bl-input-md-padding-y)\",\n \"border-(length:--bl-input-md-border-width) border-input\",\n // 16px on phones so iOS does not zoom on focus, the token size from md up (shadcn's rule)\n \"text-base md:text-(length:--bl-input-md-font-size) md:leading-(--bl-input-md-line-height)\",\n \"placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground\",\n // focus: solid primary border + the shared 3px ring (color/ring is primary at 25%)\n \"focus-visible:border-primary focus-visible:ring-(length:--bl-control-ring-width) focus-visible:ring-ring\",\n // invalid is set here, never inherited from an invalid Field, so it looks the same on every\n // platform and on a standalone input (spec: States)\n \"aria-invalid:border-destructive aria-invalid:text-destructive\",\n \"aria-invalid:ring-(length:--bl-control-ring-width) aria-invalid:ring-(color:--ring-invalid)\",\n \"disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50\",\n // type=file: the browser draws its own button; Baseline styles it as Button secondary xs,\n // reading Button's tokens because Figma nests a real Button instance there\n // ... and it drops its block padding, which would otherwise push that in-flow button past the\n // bottom of the fixed-height box. content-center above does the centring instead.\n \"[&[type=file]]:py-0\",\n // ... and its start padding is Input Group's for a leading button (the addon's padding-x pulled in by\n // 0.3rem, input-group.tsx), so a file input reads like a group whose first child is a button\n \"[&[type=file]]:ps-[calc(var(--bl-input-group-md-padding-x)_-_0.3rem)]\",\n // type=number: the spinner is browser chrome Figma does not draw, and in Blink it appears on\n // hover and shifts the value. Hidden on every platform (spec: components/input/spec.md).\n \"[&[type=number]]:[appearance:textfield]\",\n \"[&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none\",\n \"file:me-(--bl-input-md-gap) file:inline-flex file:items-center file:border-0\",\n \"file:h-(--bl-button-xs-height) file:rounded-(--bl-button-xs-radius) file:px-(--bl-button-xs-padding-x)\",\n \"file:bg-secondary file:text-secondary-foreground\",\n \"file:text-(length:--bl-button-xs-font-size) file:font-(--bl-button-font-weight)\",\n ].join(\" \"),\n className,\n )}\n {...props}\n />\n );\n}\n\nexport { Input };\n",
16
+ "type": "registry:ui",
17
+ "target": "components/ui/input.tsx"
18
+ }
19
+ ],
20
+ "meta": {
21
+ "spec": "components/input/spec.md",
22
+ "figma": "476:161",
23
+ "upstream": "shadcn/ui input (base-ui flavour, source read from ui.shadcn.com 2026-09-24)",
24
+ "status": "ready"
25
+ },
26
+ "type": "registry:ui"
27
+ }
@@ -0,0 +1,15 @@
1
+ {
2
+ "$schema": "https://ui.shadcn.com/schema/registry-item.json",
3
+ "name": "interest",
4
+ "title": "Interest invokers fallback",
5
+ "description": "InterestFallback: one client leaf that gives browsers without interestfor (Firefox, Safari) the native hover/focus show and hide, with the trigger's delays. Renders nothing; does nothing where interestfor exists.",
6
+ "files": [
7
+ {
8
+ "path": "src/lib/interest.tsx",
9
+ "content": "\"use client\";\n// Interest invokers (`interestfor`) for browsers that lack them (Firefox and Safari, 2026-10), shared by Tooltip, Hover\n// Card and Navigation Menu. Renders nothing; installs ONE set of delegated listeners per document, whatever the number\n// of components using it.\n// Every browser: a target its invoker also toggles by command (commandfor, Navigation Menu's trigger) STICKS once\n// clicked, as Floating UI's useHover + useClick do for Base UI: a click opens it and losing interest no longer closes it,\n// and a click on a target interest opened keeps it open (stickIfOpen); the next click, Escape, a click outside or\n// another auto popover closes it. Natively that cancels the target's `loseinterest` event.\n// platforms/html/src/interest.js is this file's plain-JS twin for the HTML build and Shopify: keep the two in step.\n// Where the browser lacks interestfor, also:\n// - pointer over or focus on an [interestfor] element shows its target popover after the trigger's delay,\n// - pointer out or blur hides it after the close delay, unless the pointer has moved onto the target (WCAG 1.4.13),\n// - Escape hides every target this opened.\n// Delays come from the trigger's data-interest-delay / data-interest-close-delay (ms), the same values the trigger sets\n// as interest-delay-start / -end for the native path; without them, the native defaults (500 / 250ms). Safari has no\n// popover=\"hint\" either, so there a target falls back to a manual popover, which this opens and closes the same way.\nimport * as React from \"react\";\n\nconst DEFAULT_DELAY = 500;\nconst DEFAULT_CLOSE_DELAY = 250;\n\nlet installed = false;\n\nfunction install() {\n if (installed || typeof window === \"undefined\") return;\n installed = true;\n\n const sticky = new WeakSet<HTMLElement>();\n document.addEventListener(\n \"command\",\n (e) => {\n const command = (e as Event & { command?: string }).command;\n const target = e.target as HTMLElement;\n if (command !== \"toggle-popover\" && command !== \"show-popover\") return;\n if (!target.matches(\":popover-open\")) sticky.add(target); // opening by click: it sticks\n else if (!sticky.has(target)) {\n e.preventDefault(); // open by interest: the click keeps it open, and now it sticks\n sticky.add(target);\n }\n },\n true,\n );\n document.addEventListener(\"toggle\", (e) => { if ((e as ToggleEvent).newState === \"closed\") sticky.delete(e.target as HTMLElement); }, true);\n document.addEventListener(\"loseinterest\", (e) => { if (sticky.has(e.target as HTMLElement)) e.preventDefault(); }, true);\n\n if (\"interestForElement\" in HTMLButtonElement.prototype) return;\n\n const timers = new WeakMap<HTMLElement, number>();\n const invokerOf = new WeakMap<HTMLElement, Element>(); // shown target -> the invoker that showed it\n const shown = new Set<HTMLElement>();\n const ms = (el: Element, attr: string, fallback: number) => {\n const v = Number(el.getAttribute(attr));\n return el.hasAttribute(attr) && Number.isFinite(v) ? v : fallback;\n };\n const targetOf = (invoker: Element | null) => {\n const id = invoker?.getAttribute(\"interestfor\");\n return id ? document.getElementById(id) : null;\n };\n const schedule = (target: HTMLElement, fn: () => void, delay: number) => {\n window.clearTimeout(timers.get(target));\n timers.set(target, window.setTimeout(fn, delay));\n };\n const show = (invoker: Element) => {\n const target = targetOf(invoker);\n if (!target) return;\n schedule(target, () => {\n if (!target.matches(\":popover-open\")) target.showPopover();\n invokerOf.set(target, invoker);\n shown.add(target);\n }, ms(invoker, \"data-interest-delay\", DEFAULT_DELAY));\n };\n const hide = (target: HTMLElement) => {\n if (sticky.has(target)) return;\n const invoker = invokerOf.get(target);\n schedule(target, () => {\n if (target.matches(\":popover-open\")) target.hidePopover();\n shown.delete(target);\n }, invoker ? ms(invoker, \"data-interest-close-delay\", DEFAULT_CLOSE_DELAY) : DEFAULT_CLOSE_DELAY);\n };\n const keep = (target: HTMLElement) => window.clearTimeout(timers.get(target));\n const closestInvoker = (n: EventTarget | null) => (n instanceof Element ? n.closest(\"[interestfor]\") : null);\n const shownTargetAround = (n: EventTarget | null) => {\n if (!(n instanceof Node)) return null;\n for (const t of shown) if (t.contains(n)) return t;\n return null;\n };\n\n document.addEventListener(\"pointerover\", (e) => {\n const invoker = closestInvoker(e.target);\n if (invoker) {\n const target = targetOf(invoker);\n if (target && shown.has(target)) keep(target);\n else show(invoker);\n return;\n }\n const target = shownTargetAround(e.target);\n if (target) keep(target);\n });\n document.addEventListener(\"pointerout\", (e) => {\n const to = (e as PointerEvent).relatedTarget;\n const fromInvoker = closestInvoker(e.target);\n const target = fromInvoker ? targetOf(fromInvoker) : shownTargetAround(e.target);\n if (!target) return;\n // still inside the same trigger or its target: nothing to do\n if (to instanceof Node && (target.contains(to) || (fromInvoker && fromInvoker.contains(to)) || targetOf(closestInvoker(to)) === target)) return;\n if (fromInvoker && !shown.has(target)) { window.clearTimeout(timers.get(target)); return; } // left before it opened\n hide(target);\n });\n document.addEventListener(\"focusin\", (e) => {\n const invoker = closestInvoker(e.target);\n if (invoker) show(invoker);\n });\n document.addEventListener(\"focusout\", (e) => {\n const target = targetOf(closestInvoker(e.target));\n if (!target) return;\n if (!shown.has(target)) { window.clearTimeout(timers.get(target)); return; }\n hide(target);\n });\n document.addEventListener(\"keydown\", (e) => {\n if (e.key !== \"Escape\") return;\n for (const target of shown) {\n window.clearTimeout(timers.get(target));\n if (target.matches(\":popover-open\")) target.hidePopover();\n }\n shown.clear();\n });\n}\n\n/** Renders nothing; makes `interestfor` work where the browser lacks it. Render it once per component that needs it. */\nfunction InterestFallback() {\n React.useEffect(install, []);\n return null;\n}\n\nexport { InterestFallback };\n",
10
+ "type": "registry:lib",
11
+ "target": "lib/interest.tsx"
12
+ }
13
+ ],
14
+ "type": "registry:lib"
15
+ }
package/r/item.json ADDED
@@ -0,0 +1,29 @@
1
+ {
2
+ "$schema": "https://ui.shadcn.com/schema/registry-item.json",
3
+ "name": "item",
4
+ "title": "Item",
5
+ "description": "Baseline item (ready). Spec: components/item/spec.md",
6
+ "dependencies": [
7
+ "@base-ui/react",
8
+ "class-variance-authority"
9
+ ],
10
+ "registryDependencies": [
11
+ "@baseline/utils",
12
+ "@baseline/separator"
13
+ ],
14
+ "files": [
15
+ {
16
+ "path": "src/ui/item/item.tsx",
17
+ "content": "// baseline:item@0.1.0\n// Fork of shadcn/ui Item (base-ui flavour, source read 2026-10-01). Spec: components/item/spec.md.\n// Geometry comes from Figma through the item/* tokens (ADR-0017); the muted fill is the item/muted/background tint\n// (muted at 50 %, ADR-0017 §2). Upstream's gap-2 on the media, title, actions, header and footer rows has one token,\n// item/actions/gap (8px), so all five read it.\n//\n// Ladder step 3: Base UI useRender renders a <div>, or the element passed as `render` (an <a> makes the whole row a\n// link); it replaces no element (ADR-0030). No state, no directive: server-safe, as Badge.\nimport * as React from \"react\";\nimport { mergeProps } from \"@base-ui/react/merge-props\";\nimport { useRender } from \"@base-ui/react/use-render\";\nimport { cva, type VariantProps } from \"class-variance-authority\";\nimport { cn } from \"@/lib/utils\";\nimport { Separator } from \"@/registry/ui/separator/separator\";\n\n/** A list of items (role=\"list\"); the gap between them follows the items' size. */\nfunction ItemGroup({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n <div\n role=\"list\"\n data-slot=\"item-group\"\n className={cn(\n \"group/item-group flex w-full flex-col gap-(--bl-item-group-md-gap)\",\n \"has-data-[size=sm]:gap-(--bl-item-group-sm-gap) has-data-[size=xs]:gap-(--bl-item-group-xs-gap)\",\n className,\n )}\n {...props}\n />\n );\n}\n\nfunction ItemSeparator({ className, ...props }: React.ComponentProps<typeof Separator>) {\n return (\n <Separator\n data-slot=\"item-separator\"\n orientation=\"horizontal\"\n className={cn(\"my-(--bl-item-separator-margin-y)\", className)}\n {...props}\n />\n );\n}\n\nconst itemVariants = cva(\n [\n \"group/item flex w-full flex-wrap items-center rounded-(--bl-item-radius) border-(length:--bl-item-border-width)\",\n \"text-(length:--bl-item-title-font-size) leading-(--bl-item-title-line-height) transition-colors duration-100 outline-none\",\n \"focus-visible:border-ring focus-visible:ring-(length:--bl-control-ring-width) focus-visible:ring-ring\",\n \"[a]:transition-colors [a]:hover:bg-muted\",\n ].join(\" \"),\n {\n variants: {\n variant: {\n default: \"border-transparent\",\n outline: \"border-border\",\n muted: \"border-transparent bg-(--bl-item-muted-background)\",\n },\n // upstream's sm and default boxes are identical (only the image media differs); each still has its own tokens\n size: {\n md: \"gap-(--bl-item-md-gap) px-(--bl-item-md-padding-x) py-(--bl-item-md-padding-y)\",\n sm: \"gap-(--bl-item-sm-gap) px-(--bl-item-sm-padding-x) py-(--bl-item-sm-padding-y)\",\n xs: \"gap-(--bl-item-xs-gap) px-(--bl-item-xs-padding-x) py-(--bl-item-xs-padding-y) in-data-[slot=dropdown-menu-content]:p-0\",\n },\n },\n defaultVariants: { variant: \"default\", size: \"md\" },\n },\n);\n\n/**\n * A row: optional media, content (title, description) and actions. Inside an ItemGroup it is a listitem. With\n * `render={<a href=\"…\" />}` the whole row is one link (it then keeps the link's role, so do not nest other links or\n * buttons inside it).\n */\nfunction Item({\n className,\n variant = \"default\",\n size = \"md\",\n render,\n ...props\n}: useRender.ComponentProps<\"div\"> & VariantProps<typeof itemVariants>) {\n return useRender({\n defaultTagName: \"div\",\n props: mergeProps<\"div\">(\n {\n className: cn(itemVariants({ variant, size, className })),\n // Baseline addition: upstream's group is role=list with plain children. A rendered element (a link) keeps\n // its own role instead, which matters more than a well-formed list.\n ...(render ? {} : { role: \"listitem\" }),\n },\n props,\n ),\n render,\n state: { slot: \"item\", variant, size },\n });\n}\n\nconst itemMediaVariants = cva(\n [\n \"flex shrink-0 items-center justify-center gap-(--bl-item-actions-gap)\",\n \"group-has-data-[slot=item-description]/item:translate-y-0.5 group-has-data-[slot=item-description]/item:self-start\",\n \"[&_svg]:pointer-events-none\",\n ].join(\" \"),\n {\n variants: {\n variant: {\n default: \"bg-transparent\",\n icon: \"[&_svg:not([class*='size-'])]:size-(--bl-item-media-icon-size) [&_[data-slot=icon]]:size-(--bl-item-media-icon-size)\",\n image: [\n \"size-(--bl-item-media-md-size) overflow-hidden rounded-(--bl-item-media-radius)\",\n \"group-data-[size=sm]/item:size-(--bl-item-media-sm-size) group-data-[size=xs]/item:size-(--bl-item-media-xs-size)\",\n \"[&_img]:size-full [&_img]:object-cover\",\n ].join(\" \"),\n },\n },\n defaultVariants: { variant: \"default\" },\n },\n);\n\nfunction ItemMedia({ className, variant = \"default\", ...props }: React.ComponentProps<\"div\"> & VariantProps<typeof itemMediaVariants>) {\n return <div data-slot=\"item-media\" data-variant={variant} className={cn(itemMediaVariants({ variant, className }))} {...props} />;\n}\n\n/** Grows to fill the row; a second ItemContent beside it does not (a right-hand value). */\nfunction ItemContent({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n <div\n data-slot=\"item-content\"\n className={cn(\n \"flex flex-1 flex-col gap-(--bl-item-md-content-gap)\",\n \"group-data-[size=sm]/item:gap-(--bl-item-sm-content-gap) group-data-[size=xs]/item:gap-(--bl-item-xs-content-gap)\",\n \"[&+[data-slot=item-content]]:flex-none\",\n className,\n )}\n {...props}\n />\n );\n}\n\n/** One line (line-clamp-1). */\nfunction ItemTitle({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n <div\n data-slot=\"item-title\"\n className={cn(\n \"line-clamp-1 flex w-fit items-center gap-(--bl-item-actions-gap) underline-offset-4\",\n \"text-(length:--bl-item-title-font-size) leading-(--bl-item-title-line-height) font-(--bl-item-title-font-weight)\",\n className,\n )}\n {...props}\n />\n );\n}\n\n/** Two lines (line-clamp-2), muted; links inside are underlined and turn primary on hover. */\nfunction ItemDescription({ className, ...props }: React.ComponentProps<\"p\">) {\n return (\n <p\n data-slot=\"item-description\"\n className={cn(\n \"line-clamp-2 text-start font-normal text-muted-foreground\",\n \"text-(length:--bl-item-description-md-font-size) leading-(--bl-item-description-md-line-height)\",\n \"group-data-[size=sm]/item:text-(length:--bl-item-description-sm-font-size) group-data-[size=sm]/item:leading-(--bl-item-description-sm-line-height)\",\n \"group-data-[size=xs]/item:text-(length:--bl-item-description-xs-font-size) group-data-[size=xs]/item:leading-(--bl-item-description-xs-line-height)\",\n \"[&>a]:underline [&>a]:underline-offset-4 [&>a:hover]:text-primary\",\n className,\n )}\n {...props}\n />\n );\n}\n\nfunction ItemActions({ className, ...props }: React.ComponentProps<\"div\">) {\n return <div data-slot=\"item-actions\" className={cn(\"flex items-center gap-(--bl-item-actions-gap)\", className)} {...props} />;\n}\n\n/** A full-width row above the item's media and content. */\nfunction ItemHeader({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n <div\n data-slot=\"item-header\"\n className={cn(\"flex basis-full items-center justify-between gap-(--bl-item-actions-gap)\", className)}\n {...props}\n />\n );\n}\n\n/** A full-width row below the item's media and content. */\nfunction ItemFooter({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n <div\n data-slot=\"item-footer\"\n className={cn(\"flex basis-full items-center justify-between gap-(--bl-item-actions-gap)\", className)}\n {...props}\n />\n );\n}\n\nexport { Item, ItemMedia, ItemContent, ItemActions, ItemGroup, ItemSeparator, ItemTitle, ItemDescription, ItemHeader, ItemFooter };\n",
18
+ "type": "registry:ui",
19
+ "target": "components/ui/item.tsx"
20
+ }
21
+ ],
22
+ "meta": {
23
+ "spec": "components/item/spec.md",
24
+ "figma": "4360:299",
25
+ "upstream": "shadcn/ui item (base-ui flavour, source read from ui.shadcn.com 2026-09-30)",
26
+ "status": "ready"
27
+ },
28
+ "type": "registry:ui"
29
+ }