@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
package/r/kbd.json ADDED
@@ -0,0 +1,24 @@
1
+ {
2
+ "$schema": "https://ui.shadcn.com/schema/registry-item.json",
3
+ "name": "kbd",
4
+ "title": "Kbd",
5
+ "description": "Baseline kbd (draft). Spec: components/kbd/spec.md",
6
+ "registryDependencies": [
7
+ "@baseline/utils"
8
+ ],
9
+ "files": [
10
+ {
11
+ "path": "src/ui/kbd/kbd.tsx",
12
+ "content": "// baseline:kbd@0.1.0\n// Fork of shadcn/ui Kbd (source read 2026-09-30). Spec: components/kbd/spec.md.\n// Geometry comes from Figma through the kbd/* tokens (ADR-0017).\n//\n// Ladder step 1: <kbd> exists and means exactly this, so nothing else is needed. Server-safe throughout.\nimport * as React from \"react\";\nimport { cn } from \"@/lib/utils\";\n\n/**\n * A keyboard key, rendered as a key cap.\n *\n * Use the real key name — `Ctrl`, `Shift`, `Esc` — rather than a symbol. A key cap is read out as its own\n * characters, and a screen reader will mangle a glyph. `⌘` and `⌥` are fine on macOS builds where the\n * surrounding copy already establishes the platform.\n */\nfunction Kbd({ className, ...props }: React.ComponentProps<\"kbd\">) {\n return (\n <kbd\n data-slot=\"kbd\"\n className={cn(\n [\n \"pointer-events-none inline-flex w-fit select-none items-center justify-center\",\n \"min-w-(--bl-kbd-min-width) gap-(--bl-kbd-gap)\",\n \"px-(--bl-kbd-padding-x) py-(--bl-kbd-padding-y) rounded-(--bl-kbd-radius)\",\n // kbd/border-width is 0, so the cap is a fill rather than an outline. The class is kept so it gains\n // an edge the moment a designer gives that token a value (the shape Switch's border uses).\n \"border-(length:--bl-kbd-border-width) border-border\",\n \"font-sans text-(length:--bl-kbd-font-size) leading-(--bl-kbd-line-height) font-medium\",\n \"bg-muted text-muted-foreground\",\n // Inside a Button the cap has to read against that button's fill, not against the page. Figma does\n // this with Surface modes, which cascade to nested instances; CSS has no mode cascade, so the\n // container states it. currentColor is the Button's own foreground, so one rule covers every\n // variant — which is why this is not six rules.\n \"in-[[data-slot=button]]:border-transparent\",\n \"in-[[data-slot=button]]:bg-current/20 in-[[data-slot=button]]:text-current\",\n // the same inside a tooltip, upstream's in-data-[slot=tooltip-content]:bg-background/20 text-background (the\n // tooltip's text is the background colour, so currentColor is it)\n \"in-data-[slot=tooltip-content]:border-transparent\",\n \"in-data-[slot=tooltip-content]:bg-current/20 in-data-[slot=tooltip-content]:text-current\",\n \"[&_svg:not([class*='size-'])]:size-(--bl-kbd-font-size)\",\n ].join(\" \"),\n className,\n )}\n {...props}\n />\n );\n}\n\n/**\n * Several keys as one chord. It is a `<kbd>` too: the spec for the element nests them, an outer `<kbd>` being\n * the whole shortcut and each inner one a key.\n */\nfunction KbdGroup({ className, ...props }: React.ComponentProps<\"kbd\">) {\n return (\n <kbd\n data-slot=\"kbd-group\"\n className={cn(\"inline-flex items-center gap-(--bl-kbd-group-gap)\", className)}\n {...props}\n />\n );\n}\n\nexport { Kbd, KbdGroup };\n",
13
+ "type": "registry:ui",
14
+ "target": "components/ui/kbd.tsx"
15
+ }
16
+ ],
17
+ "meta": {
18
+ "spec": "components/kbd/spec.md",
19
+ "figma": "1969:55245",
20
+ "upstream": "shadcn/ui kbd (base-ui flavour, source read from ui.shadcn.com 2026-09-25)",
21
+ "status": "draft"
22
+ },
23
+ "type": "registry:ui"
24
+ }
package/r/label.json ADDED
@@ -0,0 +1,24 @@
1
+ {
2
+ "$schema": "https://ui.shadcn.com/schema/registry-item.json",
3
+ "name": "label",
4
+ "title": "Label",
5
+ "description": "Baseline label (ready). Spec: components/label/spec.md",
6
+ "registryDependencies": [
7
+ "@baseline/utils"
8
+ ],
9
+ "files": [
10
+ {
11
+ "path": "src/ui/label/label.tsx",
12
+ "content": "// baseline:label@0.1.0\n// Fork of shadcn/ui Label (base-ui flavour, source read 2026-09-25). Spec: components/label/spec.md.\n// Upstream's base-ui Label is a plain <label>, not a Base UI primitive, so this file has no dependencies\n// beyond cn and carries no 'use client'.\n//\n// Size, line height and weight are label/* tokens (ADR-0017), not fixed classes. They were fixed classes\n// until 2026-09-25 and had silently drifted: the weight said 500 while the Figma layers said 600.\nimport * as React from \"react\";\nimport { cn } from \"@/lib/utils\";\n\n/**\n * Label names a control. `htmlFor` must point at the control's id; a label that names nothing focusable is a\n * FieldTitle, not a Label. Field composes this as FieldLabel and adds its own layout on top.\n */\nfunction Label({ className, ...props }: React.ComponentProps<\"label\">) {\n return (\n <label\n data-slot=\"label\"\n className={cn(\n \"flex items-center gap-(--bl-label-gap) select-none\",\n \"text-(length:--bl-label-font-size) leading-(--bl-label-line-height) font-(--bl-label-font-weight)\",\n \"group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50\",\n \"peer-disabled:cursor-not-allowed peer-disabled:opacity-50\",\n className,\n )}\n {...props}\n />\n );\n}\n\nexport { Label };\n",
13
+ "type": "registry:ui",
14
+ "target": "components/ui/label.tsx"
15
+ }
16
+ ],
17
+ "meta": {
18
+ "spec": "components/label/spec.md",
19
+ "figma": null,
20
+ "upstream": "shadcn/ui label (base-ui flavour, source read from ui.shadcn.com 2026-09-25)",
21
+ "status": "ready"
22
+ },
23
+ "type": "registry:ui"
24
+ }
package/r/media.json ADDED
@@ -0,0 +1,33 @@
1
+ {
2
+ "$schema": "https://ui.shadcn.com/schema/registry-item.json",
3
+ "name": "media",
4
+ "title": "Media",
5
+ "description": "Baseline media (ready). Spec: components/media/spec.md",
6
+ "registryDependencies": [
7
+ "@baseline/utils",
8
+ "@baseline/aspect-ratio",
9
+ "@baseline/button",
10
+ "@baseline/icon"
11
+ ],
12
+ "files": [
13
+ {
14
+ "path": "src/ui/media/media.tsx",
15
+ "content": "// baseline:media@0.2.0\n// Baseline Media (no shadcn upstream; built on Aspect Ratio). Spec: components/media/spec.md.\n// An image, or a video shown as an image until someone asks for it: before the click no <video> or <iframe> exists,\n// so no video bytes load. Geometry comes from Figma through the media/* tokens (ADR-0017).\n//\n// Ladder step 2: figure > img + figcaption, server-rendered. With a video, MediaPlayButton (the only client component,\n// in media-play-button.tsx) swaps the poster for the player on click; without JavaScript it is a link to the video.\nimport * as React from \"react\";\nimport { cn } from \"@/lib/utils\";\nimport { AspectRatio } from \"@/registry/ui/aspect-ratio/aspect-ratio\";\nimport { MediaPlayButton, type MediaPlayback, type MediaVideo } from \"@/registry/ui/media/media-play-button\";\n\ntype MediaImage = {\n src: string;\n /** Empty for a decorative picture; the video's own title names the play button. */\n alt: string;\n /** The image's intrinsic size: reserves the space, and gives the ratio when `ratio` is left out. */\n width?: number;\n height?: number;\n srcSet?: string;\n sizes?: string;\n};\n\ntype MediaProps = Omit<React.ComponentProps<\"figure\">, \"children\"> & {\n image: MediaImage;\n /** width / height, e.g. 16 / 9. Left out, the frame takes the image's own ratio (from width and height). */\n ratio?: number;\n /** Present: a play button over the image; the click swaps in the player. */\n video?: MediaVideo;\n /** inline (default): the player replaces the poster. dialog: it plays in a Dialog and is removed when that closes. */\n playback?: MediaPlayback;\n caption?: React.ReactNode;\n};\n\n/**\n * An image, optionally a facade for a video. The frame has no radius of its own: inside a Card it takes the card's\n * corners. Playback is inline, or `dialog`: the video plays in the Baseline Dialog, which stops it when closed.\n */\nfunction Media({ image, ratio, video, playback = \"inline\", caption, className, ...props }: MediaProps) {\n const frameRatio = ratio ?? (image.width && image.height ? image.width / image.height : undefined);\n const img = (\n <img\n data-slot=\"media-image\"\n src={image.src}\n alt={image.alt}\n width={image.width}\n height={image.height}\n srcSet={image.srcSet}\n sizes={image.sizes}\n loading=\"lazy\"\n decoding=\"async\"\n className={cn(frameRatio ? \"absolute inset-0 size-full object-cover\" : \"block h-auto w-full\")}\n />\n );\n const player = video ? <MediaPlayButton video={video} playback={playback} ratio={frameRatio} /> : null;\n return (\n <figure\n data-slot=\"media\"\n className={cn(\"flex flex-col gap-(--bl-media-caption-gap)\", className)}\n {...props}\n >\n {frameRatio ? (\n <AspectRatio ratio={frameRatio} data-slot=\"media-frame\" className=\"overflow-hidden bg-muted\">\n {img}\n {player}\n </AspectRatio>\n ) : (\n <div data-slot=\"media-frame\" className=\"relative overflow-hidden bg-muted\">\n {img}\n {player}\n </div>\n )}\n {caption ? (\n <figcaption\n data-slot=\"media-caption\"\n className=\"text-(length:--bl-media-caption-font-size) leading-(--bl-media-caption-line-height) text-muted-foreground\"\n >\n {caption}\n </figcaption>\n ) : null}\n </figure>\n );\n}\n\nexport { Media };\nexport type { MediaImage, MediaPlayback, MediaVideo };\n",
16
+ "type": "registry:ui",
17
+ "target": "components/ui/media.tsx"
18
+ },
19
+ {
20
+ "path": "src/ui/media/media-play-button.tsx",
21
+ "content": "\"use client\";\n// The client leaf of Media (spec: components/media/spec.md): the play button over the poster, and the player it swaps\n// in. It renders as a link to the video, so without JavaScript the click still reaches the video; with JavaScript the\n// click is intercepted. Inline playback replaces the button with the player in place, taking the frame's size (no layout\n// shift). Dialog playback opens the Baseline Dialog and mounts the player inside it only while it is open, so closing the\n// dialog removes the player, which stops every source the same way. Focus moves to the player either way.\nimport * as React from \"react\";\nimport { cn } from \"@/lib/utils\";\nimport { Button, buttonVariants } from \"@/registry/ui/button/button\";\nimport { AspectRatio } from \"@/registry/ui/aspect-ratio/aspect-ratio\";\nimport { Icon } from \"@/registry/ui/icon/icon\";\n\ntype MediaVideo = {\n /** file: a video URL (self-hosted, or a CDN file such as Shopify's); youtube / vimeo: the video id. */\n kind: \"file\" | \"youtube\" | \"vimeo\";\n src: string;\n /** Names the play button (\"Play video: <title>\"), the embed's iframe and, in dialog playback, the dialog. */\n title: string;\n};\n\ntype MediaPlayback = \"inline\" | \"dialog\";\n\n// where the link goes without JavaScript, and what the click inserts with it\nconst watchUrl = (v: MediaVideo) =>\n v.kind === \"youtube\" ? `https://www.youtube.com/watch?v=${encodeURIComponent(v.src)}`\n : v.kind === \"vimeo\" ? `https://vimeo.com/${encodeURIComponent(v.src)}`\n : v.src;\nconst embedUrl = (v: MediaVideo) =>\n v.kind === \"youtube\" ? `https://www.youtube-nocookie.com/embed/${encodeURIComponent(v.src)}?autoplay=1`\n : `https://player.vimeo.com/video/${encodeURIComponent(v.src)}?autoplay=1`;\n\nfunction Player({ video, playerRef }: { video: MediaVideo; playerRef: React.Ref<HTMLVideoElement & HTMLIFrameElement> }) {\n return video.kind === \"file\" ? (\n <video\n ref={playerRef}\n data-slot=\"media-player\"\n src={video.src}\n controls\n autoPlay\n playsInline\n className=\"absolute inset-0 size-full object-contain\"\n />\n ) : (\n <iframe\n ref={playerRef}\n data-slot=\"media-player\"\n src={embedUrl(video)}\n title={video.title}\n allow=\"autoplay; encrypted-media; fullscreen; picture-in-picture\"\n allowFullScreen\n className=\"absolute inset-0 size-full border-0\"\n />\n );\n}\n\nfunction MediaPlayButton({\n video,\n playback = \"inline\",\n ratio = 16 / 9,\n}: {\n video: MediaVideo;\n playback?: MediaPlayback;\n /** The player's ratio in dialog playback (the poster frame's); inline, the frame already has it. */\n ratio?: number;\n}) {\n const [playing, setPlaying] = React.useState(false);\n const playerRef = React.useRef<HTMLVideoElement & HTMLIFrameElement>(null);\n // Inline, focus follows the click to the player. In the dialog it stays where showModal() puts it (the corner close):\n // focus inside a cross-origin iframe sends Escape to the embed's document, so the dialog would not close (found\n // 2026-10-02). The video autoplays either way; Tab reaches the player's controls.\n React.useEffect(() => {\n if (playing && playback === \"inline\") playerRef.current?.focus();\n }, [playing, playback]);\n // Dialog playback opens the Dialog from code (ADR-0033: a ref and showModal(), no Dialog root). showModal() runs in the\n // click handler, before the player exists, so it focuses the corner close rather than the player's iframe; the player\n // mounts on the next render.\n const dialogId = React.useId();\n const dialogRef = React.useRef<HTMLDialogElement>(null);\n\n if (playing && playback === \"inline\") return <Player video={video} playerRef={playerRef} />;\n\n // the wrapper's padding (media/play-button/inset) widens the clickable area round the button. The button is not a\n // Button size: its box, icon and radius are media/play-button/* (user, 2026-10-02), so a designer can resize it\n // without touching Button; the colours stay secondary's.\n const button = (\n <div data-slot=\"media-play\" className=\"absolute inset-0 flex items-center justify-center\">\n <div className=\"p-(--bl-media-play-button-inset)\">\n {/* an <a> with buttonVariants, not Base UI Button: it stays a link (no role=\"button\"), as on WordPress and Shopify */}\n <a\n href={watchUrl(video)}\n aria-label={`Play video: ${video.title}`}\n aria-haspopup={playback === \"dialog\" ? \"dialog\" : undefined}\n className={cn(\n buttonVariants({ variant: \"secondary\", size: \"icon-lg\" }),\n \"size-(--bl-media-play-button-size) rounded-(--bl-media-play-button-radius)\",\n \"[&_svg]:size-(--bl-media-play-button-icon-size) [&_[data-slot=icon]]:size-(--bl-media-play-button-icon-size)\",\n )}\n onClick={(e) => {\n e.preventDefault();\n if (playback === \"dialog\") dialogRef.current?.showModal();\n setPlaying(true);\n }}\n >\n <Icon name=\"play\" />\n </a>\n </div>\n </div>\n );\n if (playback === \"inline\") return button;\n\n // Dialog playback is a lightbox (user, 2026-10-05): the video alone on the backdrop, no title and no panel, the close in\n // the viewport's top-right corner. The native <dialog> fills the viewport and is transparent, so the corner is its own\n // (a fixed close inside a scaled dialog would pin to the dialog, not the viewport); a click on it outside the video is\n // a click on the <dialog> itself, which closes it. The player exists only while it is open: closing (the close, Escape,\n // a click outside) unmounts it, which stops a file, YouTube and Vimeo alike. Its name is the video's title.\n return (\n <>\n {button}\n <dialog\n id={dialogId}\n ref={dialogRef}\n data-slot=\"media-dialog\"\n aria-label={video.title}\n onClose={() => setPlaying(false)}\n onClick={(e) => {\n if (e.target === e.currentTarget) e.currentTarget.close();\n }}\n className={cn(\n \"group/media-dialog fixed inset-0 m-0 h-full max-h-none w-full max-w-none border-0 bg-transparent p-(--bl-dialog-padding) outline-none\",\n \"hidden open:flex items-center justify-center\",\n \"opacity-0 open:opacity-100 starting:open:opacity-0\",\n \"transition-[opacity,display,overlay] transition-discrete duration-200 motion-reduce:transition-none\",\n \"backdrop:bg-(--overlay) supports-backdrop-filter:backdrop:backdrop-blur-xs\",\n \"backdrop:opacity-0 open:backdrop:opacity-100 starting:open:backdrop:opacity-0\",\n \"backdrop:transition-[opacity,display,overlay] backdrop:transition-discrete backdrop:duration-200 motion-reduce:backdrop:transition-none\",\n )}\n >\n {/* first in the DOM, so showModal() focuses it (the player mounts after) */}\n <Button\n variant=\"secondary\"\n size=\"icon-md\"\n command=\"close\"\n commandfor={dialogId}\n data-slot=\"media-dialog-close\"\n className=\"absolute top-(--bl-dialog-padding) right-(--bl-dialog-padding)\"\n >\n <Icon name=\"close\" />\n <span className=\"sr-only\">Close</span>\n </Button>\n {/* the video fades and zooms in from 95 %, as Dialog's panel does (200ms here: a full-viewport lightbox needs it visible) */}\n <AspectRatio\n ratio={ratio}\n data-slot=\"media-dialog-frame\"\n className={cn(\n \"w-full max-w-(--bl-media-dialog-max-width) overflow-hidden bg-muted\",\n \"scale-95 opacity-0 group-open/media-dialog:scale-100 group-open/media-dialog:opacity-100\",\n \"starting:group-open/media-dialog:scale-95 starting:group-open/media-dialog:opacity-0\",\n \"transition-[opacity,scale] duration-200 ease-out motion-reduce:transition-none\",\n )}\n >\n {playing ? <Player video={video} playerRef={playerRef} /> : null}\n </AspectRatio>\n </dialog>\n </>\n );\n}\n\nexport { MediaPlayButton };\nexport type { MediaPlayback, MediaVideo };\n",
22
+ "type": "registry:ui",
23
+ "target": "components/ui/media-play-button.tsx"
24
+ }
25
+ ],
26
+ "meta": {
27
+ "spec": "components/media/spec.md",
28
+ "figma": "2249:44210",
29
+ "upstream": "none (Baseline component; built on shadcn's aspect-ratio)",
30
+ "status": "ready"
31
+ },
32
+ "type": "registry:ui"
33
+ }
package/r/meter.json ADDED
@@ -0,0 +1,24 @@
1
+ {
2
+ "$schema": "https://ui.shadcn.com/schema/registry-item.json",
3
+ "name": "meter",
4
+ "title": "Meter",
5
+ "description": "Baseline meter (ready). Spec: components/meter/spec.md",
6
+ "registryDependencies": [
7
+ "@baseline/utils"
8
+ ],
9
+ "files": [
10
+ {
11
+ "path": "src/ui/meter/meter.tsx",
12
+ "content": "// baseline:meter@0.1.0\n// No shadcn/ui upstream; built like Progress on purpose (spec: components/meter/spec.md). Geometry comes from Figma\n// through the meter/* tokens (ADR-0017).\n//\n// Ladder step 1: a native <meter>. Base UI's Meter renders <div role=\"meter\">, replacing the element (ADR-0030).\n// The bar is primary in every region: low/high/optimum pass through for assistive tech, but the browser's green /\n// yellow / red is overridden, since status is a scheme (PLAN §5.2). No directive: useId is server-safe.\nimport * as React from \"react\";\nimport { cn } from \"@/lib/utils\";\n\n// The track is the element, the bar its value pseudo-element; Blink/WebKit name one per region, Gecko one in total.\nconst METER_TRACK = cn(\n \"block h-(--bl-meter-track-height) w-full appearance-none overflow-hidden rounded-(--bl-meter-track-radius) border-0 bg-muted\",\n \"[&::-webkit-meter-bar]:h-(--bl-meter-track-height) [&::-webkit-meter-bar]:rounded-none [&::-webkit-meter-bar]:border-0 [&::-webkit-meter-bar]:bg-transparent [&::-webkit-meter-bar]:bg-none\",\n // the per-region bars carry a gradient background-image in Blink, which bg-primary alone would not clear\n \"[&::-webkit-meter-optimum-value]:h-(--bl-meter-track-height) [&::-webkit-meter-suboptimum-value]:h-(--bl-meter-track-height) [&::-webkit-meter-even-less-good-value]:h-(--bl-meter-track-height) [&::-webkit-meter-optimum-value]:bg-none [&::-webkit-meter-optimum-value]:bg-primary [&::-webkit-meter-suboptimum-value]:bg-none [&::-webkit-meter-suboptimum-value]:bg-primary [&::-webkit-meter-even-less-good-value]:bg-none [&::-webkit-meter-even-less-good-value]:bg-primary\",\n \"[&::-moz-meter-bar]:bg-none [&::-moz-meter-bar]:bg-primary\",\n);\n\ntype MeterProps = Omit<React.ComponentProps<\"div\">, \"children\"> & {\n value: number;\n min?: number;\n max?: number;\n low?: number;\n high?: number;\n optimum?: number;\n /** MeterLabel and/or MeterValue; the <meter> renders after them. */\n children?: React.ReactNode;\n /** Names the meter when there is no MeterLabel. */\n \"aria-label\"?: string;\n};\n\n/** A measurement inside a known range (storage used, a score). For task progress use Progress. */\nfunction Meter({\n className,\n children,\n value,\n min = 0,\n max = 100,\n low,\n high,\n optimum,\n \"aria-label\": ariaLabel,\n ...props\n}: MeterProps) {\n const id = React.useId();\n const parts = React.Children.map(children, (child) => {\n if (!React.isValidElement(child)) return child;\n if (child.type === MeterLabel) return React.cloneElement(child as React.ReactElement<React.ComponentProps<\"label\">>, { htmlFor: id });\n if (child.type === MeterValue) return React.cloneElement(child as React.ReactElement<MeterValueProps>, { value, min, max });\n return child;\n });\n return (\n <div data-slot=\"meter\" className={cn(\"flex flex-wrap gap-(--bl-meter-gap)\", className)} {...props}>\n {parts}\n <meter\n id={id}\n data-slot=\"meter-track\"\n className={METER_TRACK}\n value={value}\n min={min}\n max={max}\n low={low}\n high={high}\n optimum={optimum}\n aria-label={ariaLabel}\n />\n </div>\n );\n}\n\nfunction MeterLabel({ className, ...props }: React.ComponentProps<\"label\">) {\n return (\n <label\n data-slot=\"meter-label\"\n className={cn(\n \"text-(length:--bl-meter-label-font-size) leading-(--bl-meter-label-line-height) font-(--bl-meter-label-font-weight)\",\n className,\n )}\n {...props}\n />\n );\n}\n\ntype MeterValueProps = Omit<React.ComponentProps<\"span\">, \"children\"> & {\n value?: number;\n min?: number;\n max?: number;\n /** Replaces the percentage, e.g. \"12 GB of 15 GB\". */\n children?: React.ReactNode;\n};\n\n/** The value as a percentage of the range, right-aligned. aria-hidden: the <meter> exposes it. Set by Meter. */\nfunction MeterValue({ className, value, min = 0, max = 100, children, ...props }: MeterValueProps) {\n const text = children ?? (value == null ? null : `${Math.round(((value - min) / (max - min)) * 100)}%`);\n return (\n <span\n data-slot=\"meter-value\"\n aria-hidden=\"true\"\n className={cn(\n \"ms-auto text-(length:--bl-meter-value-font-size) leading-(--bl-meter-value-line-height) text-muted-foreground tabular-nums\",\n className,\n )}\n {...props}\n >\n {text}\n </span>\n );\n}\n\nexport { Meter, MeterLabel, MeterValue };\n",
13
+ "type": "registry:ui",
14
+ "target": "components/ui/meter.tsx"
15
+ }
16
+ ],
17
+ "meta": {
18
+ "spec": "components/meter/spec.md",
19
+ "figma": "2207:24960",
20
+ "upstream": "none (shadcn/ui has no meter; Base UI has @base-ui/react/meter, which renders <div role=meter>)",
21
+ "status": "ready"
22
+ },
23
+ "type": "registry:ui"
24
+ }
@@ -0,0 +1,25 @@
1
+ {
2
+ "$schema": "https://ui.shadcn.com/schema/registry-item.json",
3
+ "name": "native-select",
4
+ "title": "Native-select",
5
+ "description": "Baseline native-select (draft). Spec: components/native-select/spec.md",
6
+ "registryDependencies": [
7
+ "@baseline/utils",
8
+ "@baseline/icon"
9
+ ],
10
+ "files": [
11
+ {
12
+ "path": "src/ui/native-select/native-select.tsx",
13
+ "content": "// baseline:native-select@0.1.0\n// The styled native <select>. Spec: components/native-select/spec.md.\n// Geometry comes from Figma through the select/* tokens (ADR-0017) -- shared with Select, because the trigger\n// is the same box either way.\n//\n// A separate item from Select, installed and imported separately, the way upstream separates its native\n// select. The two share no markup -- this is a <select> with <option> children, Select is a button plus a\n// portalled listbox -- so they cannot be one component with a flag.\n//\n// This is the component on WordPress, Shopify and the HTML target, and the one to reach for first in React.\n// The browser owns the option list: it cannot be styled, and on a phone it beats anything a design system will\n// build. Reach for Select only when the design needs the panel (components/select/spec.md).\nimport * as React from \"react\";\nimport { cn } from \"@/lib/utils\";\nimport { Icon } from \"@/registry/ui/icon/icon\";\n\n/**\n * NativeSelect — a real `<select>` with `appearance: none` and the chevron drawn in. It owns its box and its\n * chevron and nothing else: no label, no description, no error. Those belong to Field, which swaps it in as\n * its `Control`. A select without a label is a bug.\n *\n * Server-safe: no hooks, no directive.\n */\nfunction NativeSelect({\n className,\n children,\n multiple,\n ...props\n}: Omit<React.ComponentProps<\"select\">, \"size\">) {\n return (\n <span\n data-slot=\"native-select\"\n className={cn(\n \"relative inline-flex w-full items-center\",\n // the whole control fades together, chevron included; opacity on the select alone would leave the\n // chevron at full strength over a faded box (the shape Checkbox and Radio use)\n \"has-disabled:cursor-not-allowed has-disabled:opacity-50\",\n className,\n )}\n >\n <select\n data-slot=\"native-select-control\"\n multiple={multiple}\n className={cn(\n [\n // appearance:none removes the UA arrow so the Baseline chevron is the only one\n \"w-full min-w-0 appearance-none bg-transparent text-foreground outline-none transition-colors\",\n \"rounded-(--bl-select-md-radius) border-(length:--bl-select-md-border-width) border-input\",\n \"py-(--bl-select-md-padding-y) ps-(--bl-select-md-padding-x)\",\n // room on the inline end for the chevron, which is drawn over the box\n \"pe-[calc(var(--bl-select-md-padding-x)+var(--bl-select-md-icon-size)+var(--bl-select-md-gap))]\",\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-select-md-font-size) md:leading-(--bl-select-md-line-height)\",\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 select — the same call Input made (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:cursor-not-allowed\",\n // a single select is one line tall; a multi-select grows with its options instead, which is what\n // field-sizing does where it is supported (spec: the `multiple` prop)\n \"not-[[multiple]]:h-(--bl-select-md-height)\",\n \"[&[multiple]]:field-sizing-content [&[multiple]]:min-h-(--bl-select-md-height)\",\n ].join(\" \"),\n )}\n {...props}\n >\n {children}\n </select>\n {/* The chevron is a sibling, not a background-image: a background-image cannot take the element's colour,\n so it could never follow a scheme, and it would not be the active icon set. A <select> is a replaced\n element and cannot have children, so it hangs off the wrapper. It never takes the click.\n A multi-select has no chevron — there is nothing to drop down. */}\n {!multiple && (\n <Icon\n name=\"chevron-down\"\n aria-hidden=\"true\"\n className={cn(\n \"pointer-events-none absolute end-(--bl-select-md-padding-x)\",\n \"size-(--bl-select-md-icon-size) text-muted-foreground\",\n )}\n />\n )}\n </span>\n );\n}\n\nexport { NativeSelect };\n",
14
+ "type": "registry:ui",
15
+ "target": "components/ui/native-select.tsx"
16
+ }
17
+ ],
18
+ "meta": {
19
+ "spec": "components/native-select/spec.md",
20
+ "figma": "2025:24756",
21
+ "upstream": "shadcn/ui native select (a separate registry item from select, same as here)",
22
+ "status": "draft"
23
+ },
24
+ "type": "registry:ui"
25
+ }
@@ -0,0 +1,30 @@
1
+ {
2
+ "$schema": "https://ui.shadcn.com/schema/registry-item.json",
3
+ "name": "navigation-menu",
4
+ "title": "Navigation-menu",
5
+ "description": "Baseline navigation-menu (ready). Spec: components/navigation-menu/spec.md. Native, not Base UI: a <nav> list whose triggers open <div popover> panels anchored below them (Popover API + CSS anchor positioning), on click and on hover (interestfor, the shared interest fallback elsewhere). Every link is in the HTML whether a panel is open or not",
6
+ "dependencies": [
7
+ "@base-ui/react",
8
+ "class-variance-authority"
9
+ ],
10
+ "registryDependencies": [
11
+ "@baseline/utils",
12
+ "@baseline/interest",
13
+ "@baseline/icon"
14
+ ],
15
+ "files": [
16
+ {
17
+ "path": "src/ui/navigation-menu/navigation-menu.tsx",
18
+ "content": "// baseline:navigation-menu@0.1.0\n// Fork of shadcn/ui Navigation Menu (base-ui flavour, source read 2026-10-02, re-read 2026-10-06).\n// Spec: components/navigation-menu/spec.md. Geometry and the three tints come from Figma through the navigation-menu/*\n// tokens (ADR-0017); the panel is Popover's look.\n//\n// Ladder step 2 (ADR-0006, ADR-0030; settled 2026-10-02): a <nav> list whose triggers open native <div popover=\"auto\">\n// panels placed below them by CSS anchor positioning, not Base UI's NavigationMenu, which portals every panel into one\n// shared viewport and renders a closed panel's links only once opened. Here every link is in the page, so the menu is\n// crawlable and works without JavaScript:\n// - click (and Enter / Space) toggles a panel through the invoker command (commandfor + command=\"toggle-popover\");\n// - hover opens it too, after Base UI's 50ms, through an interest invoker (interestfor, Chromium) and InterestFallback\n// (lib/interest, shared with Tooltip and Hover Card) elsewhere;\n// - popover=\"auto\" keeps one panel open at a time, closes on a click outside or Escape and returns focus.\n// No arrow keys: WAI-ARIA's disclosure navigation pattern does not need them (Base UI adds them).\n//\n// SERVER COMPONENTS with explicit ids (ADR-0033): a trigger names its content.\n// <NavigationMenu>\n// <NavigationMenuList>\n// <NavigationMenuItem>\n// <NavigationMenuTrigger commandfor=\"nav-docs\">Docs</NavigationMenuTrigger>\n// <NavigationMenuContent id=\"nav-docs\">…NavigationMenuLinks…</NavigationMenuContent>\n// </NavigationMenuItem>\n// <NavigationMenuItem>\n// <NavigationMenuLink href=\"/pricing\" className={navigationMenuTriggerStyle()}>Pricing</NavigationMenuLink>\n// </NavigationMenuItem>\n// </NavigationMenuList>\n// </NavigationMenu>\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\";\nimport { Icon } from \"@/registry/ui/icon/icon\";\n\n// Invoker commands and interest invokers are not in @types/react 19 yet (as in dialog.tsx and popover.tsx, repeated so\n// Navigation Menu installs on its own; the types must match theirs). React passes the lowercase attributes through.\ndeclare module \"react\" {\n interface ButtonHTMLAttributes<T> {\n command?: \"show-modal\" | \"close\" | \"request-close\" | \"toggle-popover\" | \"show-popover\" | \"hide-popover\" | (string & {});\n commandfor?: string;\n interestfor?: string;\n }\n}\n\ntype Align = \"start\" | \"center\" | \"end\";\n\nconst anchorName = (id: string) => `--${id.replace(/[^\\w-]/g, \"_\")}`;\n// below the trigger, along the inline axis from its start (upstream's align=\"start\"), centre or end\nconst AREA: Record<Align, string> = { start: \"block-end span-inline-end\", center: \"block-end\", end: \"block-end span-inline-start\" };\n\nfunction NavigationMenu({ className, children, ...props }: React.ComponentProps<\"nav\">) {\n return (\n <nav\n data-slot=\"navigation-menu\"\n className={cn(\"group/navigation-menu relative flex max-w-max flex-1 items-center justify-center\", className)}\n {...props}\n >\n {children}\n <InterestFallback />\n </nav>\n );\n}\n\nfunction NavigationMenuList({ className, ...props }: React.ComponentProps<\"ul\">) {\n return (\n <ul\n data-slot=\"navigation-menu-list\"\n className={cn(\"group flex flex-1 list-none items-center justify-center gap-0\", className)}\n {...props}\n />\n );\n}\n\nfunction NavigationMenuItem({ className, ...props }: React.ComponentProps<\"li\">) {\n return <li data-slot=\"navigation-menu-item\" className={cn(\"relative\", className)} {...props} />;\n}\n\n// The trigger's box; also upstream's export for a top-level link drawn as a trigger. \"Open\" is its item holding an open\n// panel (li:has(> :popover-open)), so it needs no state: upstream's data-open:bg-muted/50 as the background-open tint.\nconst navigationMenuTriggerStyle = cva([\n \"group/navigation-menu-trigger inline-flex h-(--bl-navigation-menu-trigger-height) w-max items-center justify-center\",\n \"rounded-(--bl-navigation-menu-trigger-radius) px-(--bl-navigation-menu-trigger-padding-x) py-(--bl-navigation-menu-trigger-padding-y)\",\n \"text-(length:--bl-navigation-menu-trigger-font-size) leading-(--bl-navigation-menu-trigger-line-height) font-(--bl-navigation-menu-trigger-font-weight)\",\n \"transition-all outline-none hover:bg-muted focus:bg-muted disabled:pointer-events-none disabled:opacity-50\",\n // focus keeps Baseline's ring role (already translucent), as Button's\n \"focus-visible:ring-(length:--bl-control-ring-width) focus-visible:ring-ring\",\n \"[li:has(>:popover-open)>&]:bg-(--bl-navigation-menu-trigger-background-open) [li:has(>:popover-open)>&:hover]:bg-muted [li:has(>:popover-open)>&:focus]:bg-muted\",\n]);\n\ntype NavigationMenuTriggerProps = Omit<React.ComponentProps<\"button\">, \"command\" | \"commandfor\" | \"interestfor\"> & {\n /** Required: the NavigationMenuContent's id. */\n commandfor: string;\n};\n\n/** Toggles its panel on click and opens it on hover; the panel's anchor. Upstream's chevron turns while it is open. */\nfunction NavigationMenuTrigger({ commandfor, className, style, children, ...props }: NavigationMenuTriggerProps) {\n return (\n <button\n type=\"button\"\n data-slot=\"navigation-menu-trigger\"\n command=\"toggle-popover\"\n commandfor={commandfor}\n interestfor={commandfor}\n // Base UI's NavigationMenu delays (50 / 50ms): interest-delay-* natively, data-interest-* for InterestFallback\n data-interest-delay=\"50\"\n data-interest-close-delay=\"50\"\n className={cn(navigationMenuTriggerStyle(), \"group\", className)}\n style={{\n anchorName: anchorName(commandfor),\n ...({ interestDelayStart: \"50ms\", interestDelayEnd: \"50ms\" } as React.CSSProperties),\n ...style,\n }}\n {...props}\n >\n {children}\n <Icon\n name=\"chevron-down\"\n aria-hidden=\"true\"\n className={cn(\n \"relative top-px ms-(--bl-navigation-menu-trigger-icon-gap) size-(--bl-navigation-menu-trigger-icon-size) transition duration-300\",\n \"[li:has(>:popover-open)>button>&]:rotate-180\",\n )}\n />\n </button>\n );\n}\n\ntype NavigationMenuContentProps = Omit<React.ComponentProps<\"div\">, \"id\"> & {\n /** Required: the id the trigger points at. */\n id: string;\n /** along the trigger (default start, upstream's) */\n align?: Align;\n};\n\n// Closed it is hidden through a variant (not-open:hidden, as Popover's), so `display` can transition with it.\n// Upstream's popup motion: scale from 90% and fade, 350ms on its ease-out curve, 150ms out; none under reduced motion.\nconst contentClasses = [\n \"inset-auto m-0 not-open:hidden overflow-visible\",\n \"rounded-(--bl-navigation-menu-content-radius) border-0 bg-popover p-(--bl-navigation-menu-content-padding) text-popover-foreground shadow-sm outline-none\",\n \"ring-1 ring-(--bl-navigation-menu-content-border)\",\n \"origin-top scale-90 opacity-0 open:scale-100 open:opacity-100 starting:open:scale-90 starting:open:opacity-0\",\n \"transition-[opacity,scale,display,overlay] transition-discrete duration-350 ease-[cubic-bezier(0.22,1,0.36,1)] not-open:duration-150\",\n \"motion-reduce:transition-none\",\n \"**:data-[slot=navigation-menu-link]:focus:ring-0 **:data-[slot=navigation-menu-link]:focus:outline-none\",\n];\n\n/** The panel: <div popover=\"auto\">, below its trigger, offset by navigation-menu/content/offset. */\nfunction NavigationMenuContent({ id, align = \"start\", className, style, ...props }: NavigationMenuContentProps) {\n return (\n <div\n id={id}\n popover=\"auto\"\n data-slot=\"navigation-menu-content\"\n data-align={align}\n className={cn(contentClasses, className)}\n style={{\n positionAnchor: anchorName(id),\n positionArea: AREA[align],\n positionTryFallbacks: \"flip-inline\",\n marginBlockStart: \"var(--bl-navigation-menu-content-offset)\",\n ...style,\n }}\n {...props}\n />\n );\n}\n\ntype NavigationMenuLinkProps = useRender.ComponentProps<\"a\"> & {\n /** The current page: aria-current=\"page\" and upstream's data-active look. */\n active?: boolean;\n};\n\n/** A link, in a panel or as a top-level item; your router's link through `render`. */\nfunction NavigationMenuLink({ active, className, render, ...props }: NavigationMenuLinkProps) {\n return useRender({\n defaultTagName: \"a\",\n render,\n props: mergeProps<\"a\">(\n {\n \"aria-current\": active ? \"page\" : undefined,\n className: cn(\n \"flex items-center gap-(--bl-navigation-menu-link-gap) rounded-(--bl-navigation-menu-link-radius) p-(--bl-navigation-menu-link-padding)\",\n \"text-(length:--bl-navigation-menu-link-font-size) leading-(--bl-navigation-menu-link-line-height) transition-all outline-none\",\n \"hover:bg-muted focus:bg-muted focus-visible:ring-(length:--bl-control-ring-width) focus-visible:ring-ring\",\n \"aria-[current=page]:bg-(--bl-navigation-menu-link-background-active) aria-[current=page]:hover:bg-muted aria-[current=page]:focus:bg-muted\",\n // Icon always carries size-4, so its size is set outright (as Button); a raw svg keeps upstream's not-size- guard\n \"[&_[data-slot=icon]]:size-(--bl-navigation-menu-link-icon-size) [&_svg:not([class*='size-'])]:size-(--bl-navigation-menu-link-icon-size)\",\n className,\n ),\n },\n props,\n ),\n state: { slot: \"navigation-menu-link\" },\n });\n}\n\nexport {\n NavigationMenu,\n NavigationMenuContent,\n NavigationMenuItem,\n NavigationMenuLink,\n NavigationMenuList,\n NavigationMenuTrigger,\n navigationMenuTriggerStyle,\n};\nexport type { NavigationMenuContentProps, NavigationMenuLinkProps, NavigationMenuTriggerProps };\n",
19
+ "type": "registry:ui",
20
+ "target": "components/ui/navigation-menu.tsx"
21
+ }
22
+ ],
23
+ "meta": {
24
+ "spec": "components/navigation-menu/spec.md",
25
+ "figma": "2537:49294",
26
+ "upstream": "shadcn/ui navigation-menu (base-ui flavour, source read from ui.shadcn.com 2026-10-02, re-read 2026-10-06, unchanged)",
27
+ "status": "ready"
28
+ },
29
+ "type": "registry:ui"
30
+ }
@@ -0,0 +1,27 @@
1
+ {
2
+ "$schema": "https://ui.shadcn.com/schema/registry-item.json",
3
+ "name": "pagination-bordered",
4
+ "title": "Pagination (bordered)",
5
+ "description": "Baseline pagination, the bordered look (ADR-0031: install one look per project; it replaces @baseline/pagination). Spec: components/pagination/spec.md",
6
+ "registryDependencies": [
7
+ "@baseline/utils",
8
+ "@baseline/button",
9
+ "@baseline/icon"
10
+ ],
11
+ "files": [
12
+ {
13
+ "path": "src/ui/pagination/pagination.bordered.tsx",
14
+ "content": "// baseline:pagination-bordered@0.3.0\n// Pagination, BORDERED look (ADR-0031: installed instead of pagination.tsx, as components/ui/pagination.tsx). Same parts\n// and API as the default look; spec: components/pagination/spec.md.\n//\n// A rule across the top of the nav; links are ghost Button md boxes in muted text with an indicator on that rule: clear,\n// pagination/bordered/indicator-hover on hover, primary on the current page, whose text is primary too. Previous and\n// Next sit at the ends. After Tailwind UI's \"card footer with page buttons\" (user, 2026-10-01), at the default look's\n// spacing (user, 2026-10-02): the boxes, gaps and paddings are Button md's and pagination/default/gap, so the only\n// bordered variables are the rule, the indicator width and its hover tint (ADR-0017).\n//\n// Baseline changes (user, 2026-10-02): Previous/Next always show their text and are always rendered (`disabled` on the\n// first or last page); in a nav narrower than container/lg (32rem) (container query) the page numbers and the ellipsis hide. Links are\n// plain <a>s, not Base UI Buttons (no role=\"button\"). Ladder step 1: nav > ul > li > a, no JS. Server-safe.\nimport * as React from \"react\";\nimport { cn } from \"@/lib/utils\";\nimport { buttonVariants, type ButtonProps } from \"@/registry/ui/button/button\";\nimport { Icon } from \"@/registry/ui/icon/icon\";\n\nfunction Pagination({ className, ...props }: React.ComponentProps<\"nav\">) {\n return (\n <nav\n role=\"navigation\"\n aria-label=\"pagination\"\n data-slot=\"pagination\"\n className={cn(\"@container/pagination mx-auto flex w-full border-t-(length:--bl-pagination-bordered-border-width) border-border\", className)}\n {...props}\n />\n );\n}\n\n/** The list overlaps the rule by its width, so a link's indicator sits on it. Previous/Next items take the ends. */\nfunction PaginationContent({ className, ...props }: React.ComponentProps<\"ul\">) {\n return (\n <ul\n data-slot=\"pagination-content\"\n className={cn(\n \"-mt-(--bl-pagination-bordered-border-width) flex w-full items-center justify-center gap-(--bl-pagination-default-gap)\",\n \"[&>li:has(>[data-direction])]:flex [&>li:has(>[data-direction])]:flex-1 [&>li:has(>[data-direction=next])]:justify-end\",\n \"@max-lg/pagination:[&>li:not(:has([data-direction]))]:hidden\",\n className,\n )}\n {...props}\n />\n );\n}\n\nfunction PaginationItem({ ...props }: React.ComponentProps<\"li\">) {\n return <li data-slot=\"pagination-item\" {...props} />;\n}\n\n// the indicator: a top border inside the Button box, the box's radius dropped so it runs straight\nconst INDICATOR = cn(\n \"rounded-none border-0 border-t-(length:--bl-pagination-bordered-indicator-width) border-transparent text-muted-foreground\",\n);\n\ntype PaginationLinkProps = {\n isActive?: boolean;\n /** Leads nowhere (Previous on the first page, Next on the last): no href, aria-disabled, dimmed and inert. */\n disabled?: boolean;\n} & Pick<ButtonProps, \"size\"> &\n React.ComponentProps<\"a\">;\n\n/** A page link; the current page (aria-current=\"page\") takes the primary indicator and text. */\nfunction PaginationLink({ className, isActive, disabled, href, size = \"icon-md\", ...props }: PaginationLinkProps) {\n return (\n <a\n href={disabled ? undefined : href}\n role={disabled ? \"link\" : undefined}\n aria-disabled={disabled || undefined}\n aria-current={isActive ? \"page\" : undefined}\n data-slot=\"pagination-link\"\n data-active={isActive}\n className={cn(\n buttonVariants({ variant: \"ghost\", size }),\n INDICATOR,\n \"hover:bg-transparent hover:border-(color:--bl-pagination-bordered-indicator-hover) hover:text-foreground\",\n \"aria-[current=page]:border-primary aria-[current=page]:text-primary\",\n className,\n )}\n {...props}\n />\n );\n}\n\nfunction PaginationPrevious({ className, text = \"Previous\", ...props }: React.ComponentProps<typeof PaginationLink> & { text?: string }) {\n return (\n <PaginationLink aria-label=\"Go to previous page\" size=\"md\" data-direction=\"previous\" className={className} {...props}>\n <Icon name=\"chevron-left\" data-icon=\"inline-start\" className=\"rtl:-scale-x-100\" />\n <span>{text}</span>\n </PaginationLink>\n );\n}\n\nfunction PaginationNext({ className, text = \"Next\", ...props }: React.ComponentProps<typeof PaginationLink> & { text?: string }) {\n return (\n <PaginationLink aria-label=\"Go to next page\" size=\"md\" data-direction=\"next\" className={className} {...props}>\n <span>{text}</span>\n <Icon name=\"chevron-right\" data-icon=\"inline-end\" className=\"rtl:-scale-x-100\" />\n </PaginationLink>\n );\n}\n\n/** Elided pages, in a link's box. Only the glyph is hidden from assistive tech, so \"More pages\" is announced. */\nfunction PaginationEllipsis({ className, ...props }: React.ComponentProps<\"span\">) {\n return (\n <span\n data-slot=\"pagination-ellipsis\"\n className={cn(\n \"flex size-(--bl-button-md-height) items-center justify-center border-t-(length:--bl-pagination-bordered-indicator-width) border-transparent text-muted-foreground\",\n \"[&_svg:not([class*='size-'])]:size-(--bl-button-md-icon-size) [&_[data-slot=icon]]:size-(--bl-button-md-icon-size)\",\n className,\n )}\n {...props}\n >\n <Icon name=\"more-horizontal\" />\n <span className=\"sr-only\">More pages</span>\n </span>\n );\n}\n\nexport { Pagination, PaginationContent, PaginationEllipsis, PaginationItem, PaginationLink, PaginationNext, PaginationPrevious };\n",
15
+ "type": "registry:ui",
16
+ "target": "components/ui/pagination.tsx"
17
+ }
18
+ ],
19
+ "meta": {
20
+ "spec": "components/pagination/spec.md",
21
+ "figma": "1706:5326",
22
+ "upstream": "shadcn/ui pagination (base-ui flavour, source read from ui.shadcn.com 2026-09-30, re-read 2026-10-02)",
23
+ "status": "ready",
24
+ "look": "bordered"
25
+ },
26
+ "type": "registry:ui"
27
+ }
@@ -0,0 +1,27 @@
1
+ {
2
+ "$schema": "https://ui.shadcn.com/schema/registry-item.json",
3
+ "name": "pagination-grouped",
4
+ "title": "Pagination (grouped)",
5
+ "description": "Baseline pagination, the grouped look (ADR-0031: install one look per project; it replaces @baseline/pagination). Spec: components/pagination/spec.md",
6
+ "registryDependencies": [
7
+ "@baseline/utils",
8
+ "@baseline/button",
9
+ "@baseline/icon"
10
+ ],
11
+ "files": [
12
+ {
13
+ "path": "src/ui/pagination/pagination.grouped.tsx",
14
+ "content": "// baseline:pagination-grouped@0.3.0\n// Pagination, GROUPED look (ADR-0031: installed instead of pagination.tsx, as components/ui/pagination.tsx). Same parts\n// and API as the default look; spec: components/pagination/spec.md.\n//\n// The links fuse into one outline the way ButtonGroup joins its children (they sit inside <li>s, so its direct-child\n// seam rules are applied one level down); the current page is filled with accent. Links are <a>s wearing\n// buttonVariants (button/md/*, button/icon-md); the outline width is pagination/bordered/border-width (ADR-0017).\n//\n// Baseline changes (user, 2026-10-02): Previous/Next always show their text and are always rendered (`disabled` on the\n// first or last page); in a nav narrower than container/lg (32rem) (container query) the page numbers and the ellipsis hide, so\n// Previous and Next remain as a group of two. Links are plain <a>s, not Base UI Buttons (no role=\"button\"). Server-safe.\nimport * as React from \"react\";\nimport { cn } from \"@/lib/utils\";\nimport { buttonVariants, type ButtonProps } from \"@/registry/ui/button/button\";\nimport { Icon } from \"@/registry/ui/icon/icon\";\n\nfunction Pagination({ className, ...props }: React.ComponentProps<\"nav\">) {\n return (\n <nav\n role=\"navigation\"\n aria-label=\"pagination\"\n data-slot=\"pagination\"\n className={cn(\"@container/pagination mx-auto flex w-full justify-center\", className)}\n {...props}\n />\n );\n}\n\n/** Joined, so no gap; Previous and Next are always the first and last items, so they close the group. */\nfunction PaginationContent({ className, ...props }: React.ComponentProps<\"ul\">) {\n return (\n <ul\n data-slot=\"pagination-content\"\n className={cn(\n \"flex items-center\",\n \"@max-lg/pagination:[&>li:not(:has([data-direction]))]:hidden\",\n \"[&>li:not(:first-child)>*]:rounded-s-none [&>li:not(:first-child)>*]:border-s-0 [&>li:not(:last-child)>*]:rounded-e-none\",\n className,\n )}\n {...props}\n />\n );\n}\n\nfunction PaginationItem({ ...props }: React.ComponentProps<\"li\">) {\n return <li data-slot=\"pagination-item\" {...props} />;\n}\n\ntype PaginationLinkProps = {\n isActive?: boolean;\n /** Leads nowhere (Previous on the first page, Next on the last): no href, aria-disabled, dimmed and inert. */\n disabled?: boolean;\n} & Pick<ButtonProps, \"size\"> &\n React.ComponentProps<\"a\">;\n\n/** A page link in the joined outline; the current page (aria-current=\"page\") is filled with accent. */\nfunction PaginationLink({ className, isActive, disabled, href, size = \"icon-md\", ...props }: PaginationLinkProps) {\n return (\n <a\n href={disabled ? undefined : href}\n role={disabled ? \"link\" : undefined}\n aria-disabled={disabled || undefined}\n aria-current={isActive ? \"page\" : undefined}\n data-slot=\"pagination-link\"\n data-active={isActive}\n className={cn(\n buttonVariants({ variant: \"ghost\", size }),\n \"border-(length:--bl-pagination-bordered-border-width) border-border\",\n \"aria-[current=page]:bg-accent aria-[current=page]:text-accent-foreground\",\n // disabled keeps its outline, which is the group's edge: muted text instead of Button's 50 % opacity\n \"aria-disabled:opacity-100 aria-disabled:text-muted-foreground\",\n className,\n )}\n {...props}\n />\n );\n}\n\nfunction PaginationPrevious({ className, text = \"Previous\", ...props }: React.ComponentProps<typeof PaginationLink> & { text?: string }) {\n return (\n <PaginationLink aria-label=\"Go to previous page\" size=\"md\" data-direction=\"previous\" className={className} {...props}>\n <Icon name=\"chevron-left\" data-icon=\"inline-start\" className=\"rtl:-scale-x-100\" />\n <span>{text}</span>\n </PaginationLink>\n );\n}\n\nfunction PaginationNext({ className, text = \"Next\", ...props }: React.ComponentProps<typeof PaginationLink> & { text?: string }) {\n return (\n <PaginationLink aria-label=\"Go to next page\" size=\"md\" data-direction=\"next\" className={className} {...props}>\n <span>{text}</span>\n <Icon name=\"chevron-right\" data-icon=\"inline-end\" className=\"rtl:-scale-x-100\" />\n </PaginationLink>\n );\n}\n\n/** Elided pages, a link-sized box inside the joined outline. Only the glyph is hidden, so \"More pages\" is announced. */\nfunction PaginationEllipsis({ className, ...props }: React.ComponentProps<\"span\">) {\n return (\n <span\n data-slot=\"pagination-ellipsis\"\n className={cn(\n \"flex size-(--bl-button-md-height) items-center justify-center rounded-(--bl-button-md-radius)\",\n \"border-(length:--bl-pagination-bordered-border-width) border-border\",\n \"[&_svg:not([class*='size-'])]:size-(--bl-button-md-icon-size) [&_[data-slot=icon]]:size-(--bl-button-md-icon-size)\",\n className,\n )}\n {...props}\n >\n <Icon name=\"more-horizontal\" />\n <span className=\"sr-only\">More pages</span>\n </span>\n );\n}\n\nexport { Pagination, PaginationContent, PaginationEllipsis, PaginationItem, PaginationLink, PaginationNext, PaginationPrevious };\n",
15
+ "type": "registry:ui",
16
+ "target": "components/ui/pagination.tsx"
17
+ }
18
+ ],
19
+ "meta": {
20
+ "spec": "components/pagination/spec.md",
21
+ "figma": "1706:5326",
22
+ "upstream": "shadcn/ui pagination (base-ui flavour, source read from ui.shadcn.com 2026-09-30, re-read 2026-10-02)",
23
+ "status": "ready",
24
+ "look": "grouped"
25
+ },
26
+ "type": "registry:ui"
27
+ }
@@ -0,0 +1,26 @@
1
+ {
2
+ "$schema": "https://ui.shadcn.com/schema/registry-item.json",
3
+ "name": "pagination",
4
+ "title": "Pagination",
5
+ "description": "Baseline pagination (ready). Spec: components/pagination/spec.md",
6
+ "registryDependencies": [
7
+ "@baseline/utils",
8
+ "@baseline/button",
9
+ "@baseline/icon"
10
+ ],
11
+ "files": [
12
+ {
13
+ "path": "src/ui/pagination/pagination.tsx",
14
+ "content": "// baseline:pagination@0.3.0\n// Fork of shadcn/ui Pagination (base-ui flavour, source read 2026-10-01). Spec: components/pagination/spec.md.\n// The DEFAULT look (upstream's): ghost Button links, the current page outline. One look per site (ADR-0031): the\n// bordered and grouped looks are their own files (pagination.bordered.tsx, pagination.grouped.tsx) and registry items,\n// and every one of them installs as components/ui/pagination.tsx, so a project carries one look and no variant prop.\n//\n// Links are Buttons (button/md/*, button/icon-md); the gap is pagination/default/gap (ADR-0017). Icons are system icons.\n// Ladder step 1: nav > ul > li > a, the <a> wearing buttonVariants (no Base UI here). Server-safe.\n//\n// Baseline changes (user, 2026-10-02):\n// - Previous/Next always show their text. When the nav is narrower than container/lg (32rem) (a container query, so a pagination in a\n// narrow column behaves as one on a phone), the page numbers and the ellipsis hide, leaving Previous and Next.\n// - Previous and Next are always rendered: on the first or last page the one that leads nowhere is `disabled`, a link\n// with no href and aria-disabled, so the row keeps its shape. Previous/Next carry data-direction.\nimport * as React from \"react\";\nimport { cn } from \"@/lib/utils\";\nimport { buttonVariants, type ButtonProps } from \"@/registry/ui/button/button\";\nimport { Icon } from \"@/registry/ui/icon/icon\";\n\nfunction Pagination({ className, ...props }: React.ComponentProps<\"nav\">) {\n return (\n <nav\n role=\"navigation\"\n aria-label=\"pagination\"\n data-slot=\"pagination\"\n className={cn(\"@container/pagination mx-auto flex w-full justify-center\", className)}\n {...props}\n />\n );\n}\n\n/** In a nav narrower than container/lg (32rem) only Previous and Next stay: every other item hides, leaving no empty gaps. */\nfunction PaginationContent({ className, ...props }: React.ComponentProps<\"ul\">) {\n return (\n <ul\n data-slot=\"pagination-content\"\n className={cn(\"flex items-center gap-(--bl-pagination-default-gap) @max-lg/pagination:[&>li:not(:has([data-direction]))]:hidden\", className)}\n {...props}\n />\n );\n}\n\nfunction PaginationItem({ ...props }: React.ComponentProps<\"li\">) {\n return <li data-slot=\"pagination-item\" {...props} />;\n}\n\ntype PaginationLinkProps = {\n isActive?: boolean;\n /** Leads nowhere (Previous on the first page, Next on the last): no href, aria-disabled, dimmed and inert. */\n disabled?: boolean;\n} & Pick<ButtonProps, \"size\"> &\n React.ComponentProps<\"a\">;\n\n/**\n * A page link: ghost, or outline when it is the current page (aria-current=\"page\"). A plain <a> with Button's classes,\n * not upstream's <Button render={<a/>}>: Base UI gives a non-native button role=\"button\" and tabindex=\"0\", so every\n * page link was announced as a button (Baseline fix, 2026-10-02).\n */\nfunction PaginationLink({ className, isActive, disabled, href, size = \"icon-md\", ...props }: PaginationLinkProps) {\n return (\n <a\n href={disabled ? undefined : href}\n role={disabled ? \"link\" : undefined}\n aria-disabled={disabled || undefined}\n aria-current={isActive ? \"page\" : undefined}\n data-slot=\"pagination-link\"\n data-active={isActive}\n className={cn(buttonVariants({ variant: isActive ? \"outline\" : \"ghost\", size }), className)}\n {...props}\n />\n );\n}\n\n/** The icon side tightens through Button's own token rule (padding-x minus half the gap), not upstream's `pl-1.5!`. */\nfunction PaginationPrevious({ className, text = \"Previous\", ...props }: React.ComponentProps<typeof PaginationLink> & { text?: string }) {\n return (\n <PaginationLink aria-label=\"Go to previous page\" size=\"md\" data-direction=\"previous\" className={className} {...props}>\n <Icon name=\"chevron-left\" data-icon=\"inline-start\" className=\"rtl:-scale-x-100\" />\n <span>{text}</span>\n </PaginationLink>\n );\n}\n\nfunction PaginationNext({ className, text = \"Next\", ...props }: React.ComponentProps<typeof PaginationLink> & { text?: string }) {\n return (\n <PaginationLink aria-label=\"Go to next page\" size=\"md\" data-direction=\"next\" className={className} {...props}>\n <span>{text}</span>\n <Icon name=\"chevron-right\" data-icon=\"inline-end\" className=\"rtl:-scale-x-100\" />\n </PaginationLink>\n );\n}\n\n/**\n * Elided pages, a link-sized box. Only the glyph is hidden from assistive tech, so the sr-only \"More pages\" is announced\n * (upstream hides the whole span, which silences it; the same fix as BreadcrumbEllipsis).\n */\nfunction PaginationEllipsis({ className, ...props }: React.ComponentProps<\"span\">) {\n return (\n <span\n data-slot=\"pagination-ellipsis\"\n className={cn(\n \"flex size-(--bl-button-md-height) items-center justify-center\",\n \"[&_svg:not([class*='size-'])]:size-(--bl-button-md-icon-size) [&_[data-slot=icon]]:size-(--bl-button-md-icon-size)\",\n className,\n )}\n {...props}\n >\n <Icon name=\"more-horizontal\" />\n <span className=\"sr-only\">More pages</span>\n </span>\n );\n}\n\nexport { Pagination, PaginationContent, PaginationEllipsis, PaginationItem, PaginationLink, PaginationNext, PaginationPrevious };\n",
15
+ "type": "registry:ui",
16
+ "target": "components/ui/pagination.tsx"
17
+ }
18
+ ],
19
+ "meta": {
20
+ "spec": "components/pagination/spec.md",
21
+ "figma": "1706:5326",
22
+ "upstream": "shadcn/ui pagination (base-ui flavour, source read from ui.shadcn.com 2026-09-30, re-read 2026-10-02)",
23
+ "status": "ready"
24
+ },
25
+ "type": "registry:ui"
26
+ }
package/r/panel.json ADDED
@@ -0,0 +1,27 @@
1
+ {
2
+ "$schema": "https://ui.shadcn.com/schema/registry-item.json",
3
+ "name": "panel",
4
+ "title": "Panel",
5
+ "description": "Baseline panel (draft). Spec: components/panel/spec.md",
6
+ "dependencies": [
7
+ "class-variance-authority"
8
+ ],
9
+ "registryDependencies": [
10
+ "@baseline/utils"
11
+ ],
12
+ "files": [
13
+ {
14
+ "path": "src/ui/panel/panel.tsx",
15
+ "content": "// baseline:panel@0.1.0\n// Baseline layout component (no shadcn counterpart). Spec: components/panel/spec.md.\n// A card-style box for layout: Card's own surface variables (fill, card/border tint, radius, border width), so a Panel\n// and a Card always look alike, with padding and gap on the Responsive spacing scale. None of Card's regions.\n//\n// Ladder step 1: a plain element. No state: server-safe.\nimport * as React from \"react\";\nimport { cva, type VariantProps } from \"class-variance-authority\";\nimport { cn } from \"@/lib/utils\";\n\nconst panelVariants = cva(\n \"flex flex-col rounded-(--bl-card-md-radius) bg-card text-card-foreground ring-(length:--bl-card-border-width) ring-(--bl-card-border)\",\n {\n variants: {\n paddingX: {\n none: \"px-0\",\n xs: \"px-(--bl-spacing-responsive-xs)\",\n sm: \"px-(--bl-spacing-responsive-sm)\",\n md: \"px-(--bl-spacing-responsive-md)\",\n lg: \"px-(--bl-spacing-responsive-lg)\",\n xl: \"px-(--bl-spacing-responsive-xl)\",\n \"2xl\": \"px-(--bl-spacing-responsive-2xl)\",\n },\n paddingY: {\n none: \"py-0\",\n xs: \"py-(--bl-spacing-responsive-xs)\",\n sm: \"py-(--bl-spacing-responsive-sm)\",\n md: \"py-(--bl-spacing-responsive-md)\",\n lg: \"py-(--bl-spacing-responsive-lg)\",\n xl: \"py-(--bl-spacing-responsive-xl)\",\n \"2xl\": \"py-(--bl-spacing-responsive-2xl)\",\n },\n gap: {\n none: \"gap-0\",\n xs: \"gap-(--bl-spacing-responsive-xs)\",\n sm: \"gap-(--bl-spacing-responsive-sm)\",\n md: \"gap-(--bl-spacing-responsive-md)\",\n lg: \"gap-(--bl-spacing-responsive-lg)\",\n xl: \"gap-(--bl-spacing-responsive-xl)\",\n \"2xl\": \"gap-(--bl-spacing-responsive-2xl)\",\n },\n },\n defaultVariants: { paddingX: \"xs\", paddingY: \"xs\", gap: \"sm\" },\n },\n);\n\n/**\n * Card's surface around a section's content (Section > Container > Panel > Columns). Padding and gap take a responsive\n * spacing step or `none`. For one subject with a title and actions, use Card.\n */\nfunction Panel({\n paddingX,\n paddingY,\n gap,\n className,\n ...props\n}: React.ComponentProps<\"div\"> & VariantProps<typeof panelVariants>) {\n return <div data-slot=\"panel\" className={cn(panelVariants({ paddingX, paddingY, gap }), className)} {...props} />;\n}\n\nexport { Panel, panelVariants };\n",
16
+ "type": "registry:ui",
17
+ "target": "components/ui/panel.tsx"
18
+ }
19
+ ],
20
+ "meta": {
21
+ "spec": "components/panel/spec.md",
22
+ "figma": "4585:2678",
23
+ "upstream": "none (a Baseline layout component; shadcn has no counterpart)",
24
+ "status": "draft"
25
+ },
26
+ "type": "registry:ui"
27
+ }
package/r/popover.json ADDED
@@ -0,0 +1,28 @@
1
+ {
2
+ "$schema": "https://ui.shadcn.com/schema/registry-item.json",
3
+ "name": "popover",
4
+ "title": "Popover",
5
+ "description": "Baseline popover (ready). Spec: components/popover/spec.md. Not shadcn's API (ADR-0033): no Popover root; give PopoverContent an id and open it with <PopoverTrigger commandfor=\"<id>\" render={<Button />}>",
6
+ "dependencies": [
7
+ "@base-ui/react",
8
+ "class-variance-authority"
9
+ ],
10
+ "registryDependencies": [
11
+ "@baseline/utils"
12
+ ],
13
+ "files": [
14
+ {
15
+ "path": "src/ui/popover/popover.tsx",
16
+ "content": "// baseline:popover@0.2.0\n// Fork of shadcn/ui Popover (base-ui flavour, source read 2026-10-02). Spec: components/popover/spec.md.\n// Geometry and the border tint come from Figma through the popover/* tokens (ADR-0017).\n//\n// Ladder step 1 (ADR-0006, ADR-0030): a native <div popover> toggled by an invoker command and placed by CSS anchor\n// positioning, not Base UI's Popover, which portals and positions a <div> in script. The browser provides the rest:\n// top layer (no portal, no z-index), one auto popover open at a time, light dismiss on an outside click, Escape, and\n// the panel next in the tab order after its trigger. Focus is not moved on open (native behaviour, user 2026-10-05).\n//\n// A SERVER COMPONENT with explicit ids (ADR-0033, as Dialog and Sheet): no Popover root, no context. The trigger is\n// unstyled, as upstream's: an element of your own through `render` (useRender, which runs on the server).\n// <PopoverTrigger commandfor=\"dimensions\" render={<Button variant=\"outline\" />}>Open popover</PopoverTrigger>\n// <PopoverContent id=\"dimensions\" align=\"start\">\n// <PopoverHeader>\n// <PopoverTitle id=\"dimensions-title\">Dimensions</PopoverTitle>\n// <PopoverDescription id=\"dimensions-description\">Set the dimensions for the layer.</PopoverDescription>\n// </PopoverHeader>\n// </PopoverContent>\n// The trigger names the anchor (anchor-name --<id>) and the content uses it (position-anchor), so placement does not\n// depend on the browser treating the invoker as the implicit anchor.\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\";\n\n// Invoker commands (Baseline: Chrome 135, Firefox 144, Safari 26.2) are not in @types/react 19 yet (as in dialog.tsx,\n// repeated so Popover installs on its own). React passes the lowercase attributes through.\ndeclare module \"react\" {\n interface ButtonHTMLAttributes<T> {\n command?: \"show-modal\" | \"close\" | \"request-close\" | \"toggle-popover\" | \"show-popover\" | \"hide-popover\" | (string & {});\n commandfor?: string;\n }\n}\n\ntype Side = \"top\" | \"bottom\" | \"left\" | \"right\" | \"inline-start\" | \"inline-end\";\ntype Align = \"start\" | \"center\" | \"end\";\n\n// an id as a dashed ident: anything outside [A-Za-z0-9_-] becomes \"_\"\nconst anchorName = (id: string) => `--${id.replace(/[^\\w-]/g, \"_\")}`;\n\n// position-area for each side and alignment: the panel sits in the side's row or column, centred on the trigger or\n// spanning away from the aligned edge so the two edges line up\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};\n// the margin facing the trigger (sideOffset), the flip that keeps it on screen, and the edge alignOffset pushes from\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 0.5rem slide in from the trigger's side, as transitions from @starting-style; none under reduced motion.\nconst contentVariants = cva(\n [\n \"group/popover-content inset-auto m-0 flex not-open:hidden w-(--bl-popover-width) max-w-[calc(100vw-1rem)] flex-col gap-(--bl-popover-gap)\",\n \"overflow-visible rounded-(--bl-popover-radius) border-0 bg-popover p-(--bl-popover-padding) text-sm text-popover-foreground shadow-md outline-none\",\n \"ring-(length:--bl-popover-border-width) ring-(--bl-popover-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 PopoverTriggerProps = Omit<useRender.ComponentProps<\"button\">, \"command\" | \"commandfor\"> & {\n /** Required: the PopoverContent's id. */\n commandfor: string;\n};\n\n/**\n * Toggles the popover (command=\"toggle-popover\") and is its anchor. Unstyled, as upstream's: a <button type=\"button\">,\n * or your own element through `render` (`render={<Button variant=\"outline\" />}`). It has to end up a <button>, the only\n * element invoker commands work on.\n */\nfunction PopoverTrigger({ commandfor, render, ...props }: PopoverTriggerProps) {\n return useRender({\n defaultTagName: \"button\",\n render,\n props: mergeProps<\"button\">(\n { type: \"button\", command: \"toggle-popover\", commandfor, style: { anchorName: anchorName(commandfor) } },\n props,\n ),\n state: { slot: \"popover-trigger\" },\n });\n}\n\ntype PopoverContentProps = Omit<React.ComponentProps<\"div\">, \"id\"> & {\n /** Required: the id the trigger points at. The title and description take `<id>-title` / `<id>-description`. */\n id: string;\n side?: Side;\n align?: Align;\n /** px between the trigger and the panel (default 4) */\n sideOffset?: number;\n /** px along the side, away from the aligned edge (start / end only) */\n alignOffset?: number;\n};\n\n/**\n * The panel: <div popover=\"auto\" role=\"dialog\">. `aria-labelledby` / `aria-describedby` default to `<id>-title` /\n * `<id>-description`; pass your own (or `aria-label`) to override.\n */\nfunction PopoverContent({\n id,\n side = \"bottom\",\n align = \"center\",\n sideOffset = 4,\n alignOffset = 0,\n className,\n style,\n ...props\n}: PopoverContentProps) {\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=\"auto\"\n role=\"dialog\"\n data-slot=\"popover-content\"\n data-side={side}\n data-align={align}\n aria-labelledby={props[\"aria-label\"] ? undefined : `${id}-title`}\n aria-describedby={`${id}-description`}\n className={cn(contentVariants({ side }), className)}\n style={{ ...placement, ...style }}\n {...props}\n />\n );\n}\n\nfunction PopoverHeader({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n <div data-slot=\"popover-header\" className={cn(\"flex flex-col gap-(--bl-popover-header-gap) text-sm\", className)} {...props} />\n );\n}\n\n/** Give it `<popover id>-title`: the panel's accessible name points there. Upstream's Base UI Title is an h2. */\nfunction PopoverTitle({ className, ...props }: React.ComponentProps<\"h2\">) {\n return (\n <h2\n data-slot=\"popover-title\"\n className={cn(\n \"m-0 text-(length:--bl-popover-title-font-size) leading-(--bl-popover-title-line-height) font-(--bl-popover-title-font-weight)\",\n className,\n )}\n {...props}\n />\n );\n}\n\n/** Give it `<popover id>-description`: the panel's accessible description points there. */\nfunction PopoverDescription({ className, ...props }: React.ComponentProps<\"p\">) {\n return (\n <p\n data-slot=\"popover-description\"\n className={cn(\n \"m-0 text-(length:--bl-popover-description-font-size) leading-(--bl-popover-description-line-height) text-muted-foreground\",\n className,\n )}\n {...props}\n />\n );\n}\n\nexport { PopoverContent, PopoverDescription, PopoverHeader, PopoverTitle, PopoverTrigger };\nexport type { PopoverContentProps, PopoverTriggerProps };\n",
17
+ "type": "registry:ui",
18
+ "target": "components/ui/popover.tsx"
19
+ }
20
+ ],
21
+ "meta": {
22
+ "spec": "components/popover/spec.md",
23
+ "figma": "483:17",
24
+ "upstream": "shadcn/ui popover (base-ui flavour, source read from ui.shadcn.com 2026-10-02)",
25
+ "status": "ready"
26
+ },
27
+ "type": "registry:ui"
28
+ }
@@ -0,0 +1,34 @@
1
+ {
2
+ "$schema": "https://ui.shadcn.com/schema/registry-item.json",
3
+ "name": "progress",
4
+ "title": "Progress",
5
+ "description": "Baseline progress (ready). Spec: components/progress/spec.md",
6
+ "registryDependencies": [
7
+ "@baseline/utils"
8
+ ],
9
+ "files": [
10
+ {
11
+ "path": "src/ui/progress/progress.tsx",
12
+ "content": "// baseline:progress@0.1.0\n// Fork of shadcn/ui Progress (base-ui flavour, source read 2026-10-01). Spec: components/progress/spec.md.\n// Geometry comes from Figma through the progress/* tokens (ADR-0017).\n//\n// Ladder step 1: a native <progress>. Upstream's @base-ui/react/progress renders <div role=\"progressbar\">, replacing the\n// element, which ADR-0030 rules out. So there is no ProgressTrack / ProgressIndicator: the track and bar are the\n// element's own pseudo-elements. No primitive and no state, so no directive: useId is server-safe.\nimport * as React from \"react\";\nimport { cn } from \"@/lib/utils\";\n\n// The track is the element, the bar its value pseudo-element (WebKit/Blink and Gecko name them differently).\n// Indeterminate (no value): a 40 % bar drawn as a background on the element sweeps across, from the keyframes in\n// progress.css.json; with reduced motion the whole track fills and pulses slowly instead (approved 2026-09-30;\n// re-confirmed by the user 2026-10-01 after a brief switch to a pulse for everyone).\nconst PROGRESS_TRACK = cn(\n \"block h-(--bl-progress-track-height) w-full appearance-none overflow-hidden rounded-(--bl-progress-track-radius) border-0 bg-muted\",\n \"[&::-webkit-progress-bar]:bg-transparent [&::-webkit-progress-value]:bg-primary [&::-moz-progress-bar]:bg-primary\",\n \"indeterminate:bg-[image:linear-gradient(var(--primary),var(--primary))] indeterminate:bg-size-[40%_100%] indeterminate:bg-no-repeat\",\n \"indeterminate:animate-[bl-progress-sweep_1.5s_ease-in-out_infinite] indeterminate:[&::-moz-progress-bar]:bg-transparent\",\n \"motion-reduce:indeterminate:bg-size-[100%_100%] motion-reduce:indeterminate:animate-pulse motion-reduce:indeterminate:[animation-duration:3s]\",\n);\n\ntype ProgressProps = Omit<React.ComponentProps<\"div\">, \"children\"> & {\n /** 0 to max; leave it out for indeterminate. */\n value?: number | null;\n max?: number;\n /** ProgressLabel and/or ProgressValue, as upstream; the <progress> renders after them. */\n children?: React.ReactNode;\n /** Names the bar when there is no ProgressLabel. */\n \"aria-label\"?: string;\n};\n\n/**\n * How far a task has got. ProgressLabel and ProgressValue are optional children, wired to the <progress> here (the\n * label's `for`, the value's number), because the element itself cannot hold them.\n */\nfunction Progress({ className, children, value, max = 100, \"aria-label\": ariaLabel, ...props }: ProgressProps) {\n const id = React.useId();\n const parts = React.Children.map(children, (child) => {\n if (!React.isValidElement(child)) return child;\n if (child.type === ProgressLabel) return React.cloneElement(child as React.ReactElement<React.ComponentProps<\"label\">>, { htmlFor: id });\n if (child.type === ProgressValue) return React.cloneElement(child as React.ReactElement<ProgressValueProps>, { value, max });\n return child;\n });\n return (\n <div data-slot=\"progress\" className={cn(\"flex flex-wrap gap-(--bl-progress-gap)\", className)} {...props}>\n {parts}\n <progress\n id={id}\n data-slot=\"progress-track\"\n className={PROGRESS_TRACK}\n max={max}\n aria-label={ariaLabel}\n {...(value == null ? {} : { value })}\n />\n </div>\n );\n}\n\nfunction ProgressLabel({ className, ...props }: React.ComponentProps<\"label\">) {\n return (\n <label\n data-slot=\"progress-label\"\n className={cn(\n \"text-(length:--bl-progress-label-font-size) leading-(--bl-progress-label-line-height) font-(--bl-progress-label-font-weight)\",\n className,\n )}\n {...props}\n />\n );\n}\n\ntype ProgressValueProps = Omit<React.ComponentProps<\"span\">, \"children\"> & {\n value?: number | null;\n max?: number;\n /** Replaces the percentage, e.g. \"3 of 4\". */\n children?: React.ReactNode;\n};\n\n/** The percentage, right-aligned. aria-hidden: the <progress> already exposes the value. Set by Progress. */\nfunction ProgressValue({ className, value, max = 100, children, ...props }: ProgressValueProps) {\n const text = children ?? (value == null ? null : `${Math.round((value / max) * 100)}%`);\n return (\n <span\n data-slot=\"progress-value\"\n aria-hidden=\"true\"\n className={cn(\n \"ms-auto text-(length:--bl-progress-value-font-size) leading-(--bl-progress-value-line-height) text-muted-foreground tabular-nums\",\n className,\n )}\n {...props}\n >\n {text}\n </span>\n );\n}\n\nexport { Progress, ProgressLabel, ProgressValue };\n",
13
+ "type": "registry:ui",
14
+ "target": "components/ui/progress.tsx"
15
+ }
16
+ ],
17
+ "css": {
18
+ "@keyframes bl-progress-sweep": {
19
+ "from": {
20
+ "background-position": "-66.667% 0"
21
+ },
22
+ "to": {
23
+ "background-position": "166.667% 0"
24
+ }
25
+ }
26
+ },
27
+ "meta": {
28
+ "spec": "components/progress/spec.md",
29
+ "figma": "4431:16952",
30
+ "upstream": "shadcn/ui progress (base-ui flavour, source read from ui.shadcn.com 2026-09-30)",
31
+ "status": "ready"
32
+ },
33
+ "type": "registry:ui"
34
+ }
package/r/radio.json ADDED
@@ -0,0 +1,24 @@
1
+ {
2
+ "$schema": "https://ui.shadcn.com/schema/registry-item.json",
3
+ "name": "radio",
4
+ "title": "Radio",
5
+ "description": "Baseline radio (draft). Spec: components/radio/spec.md",
6
+ "registryDependencies": [
7
+ "@baseline/utils"
8
+ ],
9
+ "files": [
10
+ {
11
+ "path": "src/ui/radio/radio.tsx",
12
+ "content": "// baseline:radio@0.1.0\n// Fork of shadcn/ui RadioGroup (base-ui flavour, source read 2026-09-29). Spec: components/radio/spec.md.\n// Geometry comes from Figma through the radio/* tokens (ADR-0017).\n//\n// ADR-0030: these are real <input type=\"radio\">, not Base UI's <button role=\"radio\">. Base UI replaces the\n// element; native does not, and three of the four platforms have no React. Radio has a second argument\n// Checkbox does not: a shared `name` gives single-selection semantics and arrow-key roving focus with no\n// JavaScript, which is most of what the Base UI primitive re-implements.\nimport * as React from \"react\";\nimport { cn } from \"@/lib/utils\";\n\n/**\n * RadioGroup — the stack that holds one Field per option. Upstream is `grid w-full gap-2`; the gap is bound\n * to `radio/group/gap` rather than left as a Tailwind step (ADR-0017).\n *\n * It is NOT FieldGroup: FieldGroup stacks whole Fields, RadioGroup stacks the radios of one Field. Its\n * accessible name comes from the FieldSet and FieldLegend around it, exactly as upstream's does.\n *\n * Server-safe: no hooks, no directive.\n */\nfunction RadioGroup({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n <div\n role=\"radiogroup\"\n data-slot=\"radio-group\"\n className={cn(\"grid w-full gap-(--bl-radio-group-gap)\", className)}\n {...props}\n />\n );\n}\n\n/**\n * RadioGroupItem — the round one-of-a-set control. It owns its box and its dot and nothing else: no label,\n * no description, no error. Those belong to Field, which swaps this in as its `Control`.\n *\n * `name` is passed per item rather than inherited from RadioGroup. A context provider would force\n * `'use client'` on a component that otherwise needs none, and the items are nested inside Field wrappers, so\n * cloning children could not reach them either. Passing it is also what the WordPress, Shopify and HTML\n * templates do, so the four platforms render the same markup.\n *\n * Server-safe: no hooks, no directive.\n */\nfunction RadioGroupItem({ className, ...props }: Omit<React.ComponentProps<\"input\">, \"type\">) {\n return (\n <span\n data-slot=\"radio-group-item\"\n className={cn(\n \"relative inline-flex shrink-0 items-center justify-center align-middle\",\n \"size-(--bl-radio-size)\",\n // the whole control fades together, dot included; opacity on the input alone would leave the dot at\n // full strength over a faded box\n \"has-disabled:cursor-not-allowed has-disabled:opacity-50\",\n className,\n )}\n >\n <input\n type=\"radio\"\n data-slot=\"radio-group-control\"\n className={cn(\n [\n \"peer size-full appearance-none bg-transparent outline-none transition-colors\",\n \"rounded-full border border-input\",\n \"checked:border-primary checked:bg-primary\",\n \"focus-visible:border-primary focus-visible:ring-(length:--bl-control-ring-width) focus-visible:ring-ring\",\n // invalid does not override checked: a selected-but-invalid radio still has to read as selected,\n // which is upstream's aria-invalid:aria-checked:border-primary\n \"aria-invalid:border-destructive\",\n \"aria-invalid:ring-(length:--bl-control-ring-width) aria-invalid:ring-(color:--ring-invalid)\",\n \"aria-invalid:checked:border-primary\",\n \"disabled:cursor-not-allowed\",\n // 16px fails WCAG 2.5.8 on touch, so the target is grown with a pseudo-element rather than with\n // padding, which would move the box. Inside a Field the label is clickable too, so this is the\n // floor rather than the whole story.\n \"after:absolute after:-inset-x-3 after:-inset-y-2 after:content-['']\",\n ].join(\" \"),\n )}\n {...props}\n />\n {/* The dot is a sibling, not a pseudo-element: a replaced element cannot have children. It is 10px, not\n upstream's size-2 (8px) -- Figma draws 10 and Figma is the source of truth (spec: Size). It never\n takes the click. */}\n <span\n aria-hidden=\"true\"\n data-slot=\"radio-group-indicator\"\n className={cn(\n \"pointer-events-none absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2\",\n \"size-(--bl-radio-dot-size) rounded-full bg-primary-foreground\",\n \"hidden peer-checked:block\",\n )}\n />\n </span>\n );\n}\n\nexport { RadioGroup, RadioGroupItem };\n",
13
+ "type": "registry:ui",
14
+ "target": "components/ui/radio.tsx"
15
+ }
16
+ ],
17
+ "meta": {
18
+ "spec": "components/radio/spec.md",
19
+ "figma": "472:98",
20
+ "upstream": "shadcn/ui radio-group (base-ui flavour, source read from ui.shadcn.com 2026-09-25)",
21
+ "status": "draft"
22
+ },
23
+ "type": "registry:ui"
24
+ }