a]:underline [&>a]:underline-offset-4 [&>a:hover]:text-primary\",\n className\n )}\n {...props}\n />\n )\n}\n\nfunction EmptyContent({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n
\n )\n}\n\nexport {\n Empty,\n EmptyHeader,\n EmptyTitle,\n EmptyDescription,\n EmptyContent,\n EmptyMedia,\n}\n"
+ "content": "import * as React from \"react\"\r\nimport { cva, type VariantProps } from \"class-variance-authority\"\r\n\r\nimport { cn } from \"@/lib/utils\"\r\n\r\nfunction Empty({ className, ...props }: React.ComponentProps<\"div\">) {\r\n return (\r\n
\r\n )\r\n}\r\n\r\nfunction EmptyHeader({ className, ...props }: React.ComponentProps<\"div\">) {\r\n return (\r\n
\r\n )\r\n}\r\n\r\nconst emptyMediaVariants = cva(\r\n \"mb-2 flex shrink-0 items-center justify-center [&_svg]:pointer-events-none [&_svg]:shrink-0\",\r\n {\r\n variants: {\r\n variant: {\r\n default: \"bg-transparent\",\r\n icon: \"flex size-8 shrink-0 items-center justify-center rounded-lg bg-muted text-foreground [&_svg:not([class*='size-'])]:size-4\",\r\n },\r\n },\r\n defaultVariants: {\r\n variant: \"default\",\r\n },\r\n }\r\n)\r\n\r\nfunction EmptyMedia({\r\n className,\r\n variant = \"default\",\r\n ...props\r\n}: React.ComponentProps<\"div\"> & VariantProps
) {\r\n return (\r\n
\r\n )\r\n}\r\n\r\nfunction EmptyTitle({ className, ...props }: React.ComponentProps<\"div\">) {\r\n return (\r\n
\r\n )\r\n}\r\n\r\nfunction EmptyDescription({\r\n className,\r\n ...props\r\n}: React.ComponentProps<\"div\">) {\r\n return (\r\n a]:underline [&>a]:underline-offset-4 [&>a:hover]:text-primary\",\r\n className\r\n )}\r\n {...props}\r\n />\r\n )\r\n}\r\n\r\nfunction EmptyContent({ className, ...props }: React.ComponentProps<\"div\">) {\r\n return (\r\n
\r\n )\r\n}\r\n\r\nexport {\r\n Empty,\r\n EmptyHeader,\r\n EmptyTitle,\r\n EmptyDescription,\r\n EmptyContent,\r\n EmptyMedia,\r\n}\r\n"
}
]
}
diff --git a/apps/web/public/r/ds-input.json b/apps/web/public/r/ds-input.json
index 09021aa..70a576c 100644
--- a/apps/web/public/r/ds-input.json
+++ b/apps/web/public/r/ds-input.json
@@ -12,7 +12,7 @@
"path": "registry/base-nova/ds-input/ds-input.tsx",
"type": "registry:ui",
"target": "@ui/ds-input.tsx",
- "content": "import * as React from \"react\"\n\nimport { cn } from \"@/lib/utils\"\n\nexport interface DsInputProps\n extends React.InputHTMLAttributes
{}\n\nexport const DsInput = React.forwardRef(\n ({ className, type, ...props }, ref) => (\n \n )\n)\nDsInput.displayName = \"DsInput\"\n\nexport { DsInput }\n"
+ "content": "import * as React from \"react\"\r\n\r\nimport { cn } from \"@/lib/utils\"\r\n\r\nexport interface DsInputProps\r\n extends React.InputHTMLAttributes {}\r\n\r\nexport const DsInput = React.forwardRef(\r\n ({ className, type, ...props }, ref) => (\r\n \r\n )\r\n)\r\nDsInput.displayName = \"DsInput\"\r\n"
}
]
}
diff --git a/apps/web/public/r/ds-tabs.json b/apps/web/public/r/ds-tabs.json
index 5816ad9..85d0582 100644
--- a/apps/web/public/r/ds-tabs.json
+++ b/apps/web/public/r/ds-tabs.json
@@ -14,7 +14,7 @@
"path": "registry/base-nova/ds-tabs/ds-tabs.tsx",
"type": "registry:ui",
"target": "@ui/ds-tabs.tsx",
- "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { Tabs as TabsPrimitive } from \"@base-ui/react/tabs\"\nimport { cva, type VariantProps } from \"class-variance-authority\"\n\nimport { cn } from \"@/lib/utils\"\n\nfunction Tabs({\n className,\n orientation = \"horizontal\",\n ...props\n}: TabsPrimitive.Root.Props) {\n return (\n \n )\n}\n\nconst tabsListVariants = cva(\n \"group/tabs-list inline-flex w-fit items-center justify-center rounded-lg p-[3px] text-muted-foreground group-data-horizontal/tabs:h-8 group-data-vertical/tabs:h-fit group-data-vertical/tabs:flex-col data-[variant=line]:rounded-none\",\n {\n variants: {\n variant: {\n default: \"bg-muted\",\n line: \"gap-1 bg-transparent\",\n },\n },\n defaultVariants: {\n variant: \"default\",\n },\n }\n)\n\nfunction TabsList({\n className,\n variant = \"default\",\n ...props\n}: TabsPrimitive.List.Props & VariantProps) {\n return (\n \n )\n}\n\nfunction TabsTrigger({ className, ...props }: TabsPrimitive.Tab.Props) {\n return (\n \n )\n}\n\nfunction TabsContent({ className, ...props }: TabsPrimitive.Panel.Props) {\n return (\n \n )\n}\n\nexport { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants }\n"
+ "content": "\"use client\"\r\n\r\nimport * as React from \"react\"\r\nimport { Tabs as TabsPrimitive } from \"@base-ui/react/tabs\"\r\nimport { cva, type VariantProps } from \"class-variance-authority\"\r\n\r\nimport { cn } from \"@/lib/utils\"\r\n\r\nfunction Tabs({\r\n className,\r\n orientation = \"horizontal\",\r\n ...props\r\n}: TabsPrimitive.Root.Props) {\r\n return (\r\n \r\n )\r\n}\r\n\r\nconst tabsListVariants = cva(\r\n \"group/tabs-list inline-flex w-fit items-center justify-center rounded-lg p-[3px] text-muted-foreground group-data-horizontal/tabs:h-8 group-data-vertical/tabs:h-fit group-data-vertical/tabs:flex-col data-[variant=line]:rounded-none\",\r\n {\r\n variants: {\r\n variant: {\r\n default: \"bg-muted\",\r\n line: \"gap-1 bg-transparent\",\r\n },\r\n },\r\n defaultVariants: {\r\n variant: \"default\",\r\n },\r\n }\r\n)\r\n\r\nfunction TabsList({\r\n className,\r\n variant = \"default\",\r\n ...props\r\n}: TabsPrimitive.List.Props & VariantProps) {\r\n return (\r\n \r\n )\r\n}\r\n\r\nfunction TabsTrigger({ className, ...props }: TabsPrimitive.Tab.Props) {\r\n return (\r\n \r\n )\r\n}\r\n\r\nfunction TabsContent({ className, ...props }: TabsPrimitive.Panel.Props) {\r\n return (\r\n \r\n )\r\n}\r\n\r\nexport { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants }\r\n"
}
]
}
diff --git a/apps/web/public/r/ds-textarea.json b/apps/web/public/r/ds-textarea.json
index ccb855d..b70bec1 100644
--- a/apps/web/public/r/ds-textarea.json
+++ b/apps/web/public/r/ds-textarea.json
@@ -12,7 +12,7 @@
"path": "registry/base-nova/ds-textarea/ds-textarea.tsx",
"type": "registry:ui",
"target": "@ui/ds-textarea.tsx",
- "content": "import * as React from \"react\"\n\nimport { cn } from \"@/lib/utils\"\n\nexport interface DsTextareaProps\n extends React.TextareaHTMLAttributes {}\n\nexport const DsTextarea = React.forwardRef<\n HTMLTextAreaElement,\n DsTextareaProps\n>(({ className, ...props }, ref) => (\n \n))\nDsTextarea.displayName = \"DsTextarea\"\n\nexport { DsTextarea }\n"
+ "content": "import * as React from \"react\"\r\n\r\nimport { cn } from \"@/lib/utils\"\r\n\r\nexport interface DsTextareaProps\r\n extends React.TextareaHTMLAttributes {}\r\n\r\nexport const DsTextarea = React.forwardRef<\r\n HTMLTextAreaElement,\r\n DsTextareaProps\r\n>(({ className, ...props }, ref) => (\r\n \r\n))\r\nDsTextarea.displayName = \"DsTextarea\"\r\n"
}
]
}
diff --git a/apps/web/public/r/registry.json b/apps/web/public/r/registry.json
index 2440a20..26f712a 100644
--- a/apps/web/public/r/registry.json
+++ b/apps/web/public/r/registry.json
@@ -147,6 +147,28 @@
"target": "@ui/ds-textarea.tsx"
}
]
+ },
+ {
+ "name": "ds-block-empty-state",
+ "type": "registry:block",
+ "title": "Empty State",
+ "description": "DeesseJS composable empty-state block — bundles ds-empty + ds-button + ds-colored-badge. Optional icon, status badge, description, and CTA. First block, single-file, validates the registry:block pipeline.",
+ "dependencies": [
+ "clsx",
+ "tailwind-merge"
+ ],
+ "registryDependencies": [
+ "ds-empty",
+ "ds-button",
+ "ds-colored-badge"
+ ],
+ "files": [
+ {
+ "path": "registry/base-nova/ds-block-empty-state/ds-block-empty-state.tsx",
+ "type": "registry:component",
+ "target": "@/components/empty-state.tsx"
+ }
+ ]
}
]
}
diff --git a/apps/web/scripts/contract-test.mjs b/apps/web/scripts/contract-test.mjs
index 049f7a9..b504a1f 100644
--- a/apps/web/scripts/contract-test.mjs
+++ b/apps/web/scripts/contract-test.mjs
@@ -9,6 +9,7 @@ import { spawnSync } from "node:child_process"
import {
cpSync,
mkdirSync,
+ readFileSync,
rmSync,
writeFileSync,
} from "node:fs"
@@ -18,6 +19,7 @@ import { fileURLToPath } from "node:url"
const HERE = dirname(fileURLToPath(import.meta.url))
const REPO_ROOT = join(HERE, "..", "..", "..")
const SOURCE_DIR = join(REPO_ROOT, "registry", "base-nova")
+const REGISTRY_JSON = join(REPO_ROOT, "registry.json")
const SHIM = join(REPO_ROOT, ".contract-test")
rmSync(SHIM, { recursive: true, force: true })
@@ -40,8 +42,30 @@ writeFileSync(
].join("\n")
)
-// Mirror registry sources into the consumer's @/components/ui.
-cpSync(SOURCE_DIR, join(SHIM, "components", "ui"), { recursive: true })
+// Mirror registry sources into the consumer's @/components/ui. We honor each
+// item's `target` field from registry.json — the same renaming the shadcn CLI
+// applies at install time — so blocks that import other ds-* components
+// resolve correctly (`@/components/ui/ds-empty` → `components/ui/ds-empty.tsx`,
+// not `components/ui/ds-empty/ds-empty.tsx`).
+const catalog = JSON.parse(readFileSync(REGISTRY_JSON, "utf-8"))
+
+function resolveTarget(target) {
+ return target
+ .replace(/^@ui\//, "components/ui/")
+ .replace(/^@components\//, "components/")
+ .replace(/^@lib\//, "lib/")
+ .replace(/^@hooks\//, "hooks/")
+ .replace(/^@\//, "./")
+}
+
+for (const item of catalog.items ?? []) {
+ for (const file of item.files ?? []) {
+ const sourcePath = join(REPO_ROOT, file.path)
+ const destPath = join(SHIM, resolveTarget(file.target))
+ mkdirSync(dirname(destPath), { recursive: true })
+ cpSync(sourcePath, destPath)
+ }
+}
writeFileSync(
join(SHIM, "tsconfig.json"),
diff --git a/docs/registry/audit-2026-07-29.json b/docs/registry/audit-2026-07-29.json
index 3efbabf..8e28155 100644
--- a/docs/registry/audit-2026-07-29.json
+++ b/docs/registry/audit-2026-07-29.json
@@ -177,6 +177,26 @@
"consumer": "@/lib/utils"
}
}
+ },
+ "ds-block-empty-state": {
+ "showcase-category": "block",
+ "consumer-path": "registry/base-nova/ds-block-empty-state/ds-block-empty-state.tsx",
+ "showcase-path": "packages/registry/src/blocks/empty-state/index.tsx",
+ "workspace-source-path": null,
+ "drift-axes": ["showcase-vs-consumer-composition"],
+ "behavioral-divergence-risk": "low",
+ "current-state": "block-composes-ds-empty-button-colored-badge-class-strings-identical-by-construction",
+ "per-check": {
+ "class-strings-showcase-vs-consumer": "strict-pass",
+ "export-name-showcase-vs-consumer": "strict-pass",
+ "composed-imports-showcase-vs-consumer": "strict-pass"
+ },
+ "allowed-divergences": {
+ "import-path-showcase-vs-consumer": {
+ "showcase": "@workspace/ui/components/empty + @workspace/ui/components/button + @workspace/registry/components/colored-badge",
+ "consumer": "@/components/ui/ds-empty + @/components/ui/ds-button + @/components/ui/ds-colored-badge + @/lib/utils"
+ }
+ }
}
}
}
diff --git a/packages/registry/src/blocks/empty-state/index.tsx b/packages/registry/src/blocks/empty-state/index.tsx
new file mode 100644
index 0000000..68c7bd5
--- /dev/null
+++ b/packages/registry/src/blocks/empty-state/index.tsx
@@ -0,0 +1,72 @@
+"use client"
+
+import * as React from "react"
+
+import {
+ Empty,
+ EmptyHeader,
+ EmptyMedia,
+ EmptyTitle,
+ EmptyDescription,
+ EmptyContent,
+} from "@workspace/ui/components/empty"
+import { Button as DsButton } from "@workspace/ui/components/button"
+import {
+ ColoredBadge,
+ type ColoredBadgeColor,
+} from "@workspace/registry/components/colored-badge"
+
+export interface EmptyStateProps {
+ title?: string
+ description?: string
+ icon?: React.ReactNode
+ badge?: { label: string; color: ColoredBadgeColor }
+ action?: { label: string; onClick?: () => void }
+ className?: string
+}
+
+export function EmptyState({
+ title = "Nothing here yet",
+ description,
+ icon,
+ badge,
+ action,
+ className,
+}: EmptyStateProps) {
+ const hasHeader = icon !== undefined || badge !== undefined
+ return (
+
+ {hasHeader ? (
+
+ {icon && {icon} }
+ {badge && (
+ {badge.label}
+ )}
+ {title}
+ {description && {description} }
+
+ ) : (
+ <>
+ {title}
+ {description && {description} }
+ >
+ )}
+ {action && (
+
+ {action.label}
+
+ )}
+
+ )
+}
+
+export function EmptyStateBlockDemo() {
+ return (
+
+ )
+}
\ No newline at end of file
diff --git a/packages/registry/src/blocks/empty-state/meta.ts b/packages/registry/src/blocks/empty-state/meta.ts
new file mode 100644
index 0000000..82317ac
--- /dev/null
+++ b/packages/registry/src/blocks/empty-state/meta.ts
@@ -0,0 +1,9 @@
+import type { BlockMeta } from "../../types.ts"
+
+export const meta: BlockMeta = {
+ id: "empty-state",
+ name: "Empty State",
+ description:
+ "Composable empty-state block bundling ds-empty + ds-button + ds-colored-badge. Optional icon, status badge, description, and CTA.",
+ category: "onboarding",
+}
\ No newline at end of file
diff --git a/packages/registry/tsconfig.json b/packages/registry/tsconfig.json
index b3b8437..e89aadc 100644
--- a/packages/registry/tsconfig.json
+++ b/packages/registry/tsconfig.json
@@ -1,7 +1,8 @@
{
"extends": "@workspace/typescript-config/react-library.json",
"compilerOptions": {
- "outDir": "dist"
+ "outDir": "dist",
+ "rootDir": "./src"
},
"include": ["src"],
"exclude": ["dist"]
diff --git a/registry.json b/registry.json
index 2440a20..26f712a 100644
--- a/registry.json
+++ b/registry.json
@@ -147,6 +147,28 @@
"target": "@ui/ds-textarea.tsx"
}
]
+ },
+ {
+ "name": "ds-block-empty-state",
+ "type": "registry:block",
+ "title": "Empty State",
+ "description": "DeesseJS composable empty-state block — bundles ds-empty + ds-button + ds-colored-badge. Optional icon, status badge, description, and CTA. First block, single-file, validates the registry:block pipeline.",
+ "dependencies": [
+ "clsx",
+ "tailwind-merge"
+ ],
+ "registryDependencies": [
+ "ds-empty",
+ "ds-button",
+ "ds-colored-badge"
+ ],
+ "files": [
+ {
+ "path": "registry/base-nova/ds-block-empty-state/ds-block-empty-state.tsx",
+ "type": "registry:component",
+ "target": "@/components/empty-state.tsx"
+ }
+ ]
}
]
}
diff --git a/registry/base-nova/ds-block-empty-state/ds-block-empty-state.tsx b/registry/base-nova/ds-block-empty-state/ds-block-empty-state.tsx
new file mode 100644
index 0000000..e85a5fc
--- /dev/null
+++ b/registry/base-nova/ds-block-empty-state/ds-block-empty-state.tsx
@@ -0,0 +1,62 @@
+"use client"
+
+import * as React from "react"
+
+import {
+ Empty,
+ EmptyHeader,
+ EmptyMedia,
+ EmptyTitle,
+ EmptyDescription,
+ EmptyContent,
+} from "@/components/ui/ds-empty"
+import { DsButton } from "@/components/ui/ds-button"
+import {
+ DsColoredBadge,
+ type DsColoredBadgeColor,
+} from "@/components/ui/ds-colored-badge"
+import { cn } from "@/lib/utils"
+
+export interface DsEmptyStateProps {
+ title?: string
+ description?: string
+ icon?: React.ReactNode
+ badge?: { label: string; color: DsColoredBadgeColor }
+ action?: { label: string; onClick?: () => void }
+ className?: string
+}
+
+export function DsEmptyState({
+ title = "Nothing here yet",
+ description,
+ icon,
+ badge,
+ action,
+ className,
+}: DsEmptyStateProps) {
+ const hasHeader = icon !== undefined || badge !== undefined
+ return (
+
+ {hasHeader ? (
+
+ {icon && {icon} }
+ {badge && (
+ {badge.label}
+ )}
+ {title}
+ {description && {description} }
+
+ ) : (
+ <>
+ {title}
+ {description && {description} }
+ >
+ )}
+ {action && (
+
+ {action.label}
+
+ )}
+
+ )
+}
\ No newline at end of file