unknown> = T extends (
+ props: infer P,
+) => unknown
+ ? Omit
+ : never;
+
+function partProps(style: stylex.StyleXStyles) {
+ const resolved = stylex.props(style);
+ return { className: resolved.className, style: resolved.style };
+}
+
+/** Anchored, nonmodal content for controls and compact supporting details. */
+export const Popover = {
+ Root: BasePopover.Root,
+
+ Trigger: function PopoverTrigger(props: Props) {
+ return (
+
+ );
+ },
+
+ Portal: function PopoverPortal(props: Props) {
+ const theme = useContext(DowelThemeContext);
+ const resolved = stylex.props(themeStyles[theme]);
+
+ return (
+
+ );
+ },
+
+ Positioner: function PopoverPositioner(
+ props: Props,
+ ) {
+ return (
+
+ );
+ },
+
+ Popup: function PopoverPopup(props: Props) {
+ return (
+
+ );
+ },
+
+ Viewport: function PopoverViewport(
+ props: Props,
+ ) {
+ return (
+
+ );
+ },
+
+ Arrow: function PopoverArrow(props: Props) {
+ return (
+
+
+
+ );
+ },
+
+ Title: function PopoverTitle(props: Props) {
+ return (
+
+ );
+ },
+
+ Description: function PopoverDescription(
+ props: Props,
+ ) {
+ return (
+
+ );
+ },
+
+ Close: function PopoverClose(props: Props) {
+ return (
+
+ );
+ },
+};
diff --git a/packages/dowel/src/components/popover/popover.stylex.ts b/packages/dowel/src/components/popover/popover.stylex.ts
new file mode 100644
index 0000000..a91ed00
--- /dev/null
+++ b/packages/dowel/src/components/popover/popover.stylex.ts
@@ -0,0 +1,82 @@
+import * as stylex from "@stylexjs/stylex";
+
+import { tokens } from "../../theme/tokens.stylex";
+
+const REDUCED_MOTION = "@media (prefers-reduced-motion: reduce)";
+
+export const popup = stylex.create({
+ root: {
+ backgroundColor: tokens["--dowel-bg-elevated"],
+ borderColor: tokens["--dowel-border-default"],
+ borderRadius: "10px",
+ borderStyle: "solid",
+ borderWidth: tokens["--dowel-hairline"],
+ boxShadow: tokens["--dowel-shadow-popover"],
+ boxSizing: "border-box",
+ color: tokens["--dowel-text-secondary"],
+ fontFamily: tokens["--dowel-font-sans"],
+ maxWidth: "min(22rem, calc(100vw - 1rem))",
+ minWidth: "12rem",
+ opacity: 1,
+ outline: "none",
+ padding: "0.75rem",
+ transformOrigin: "var(--transform-origin)",
+ transitionDuration: {
+ default: tokens["--dowel-duration-fast"],
+ [REDUCED_MOTION]: "0.01ms",
+ },
+ transitionProperty: "opacity",
+ transitionTimingFunction: tokens["--dowel-ease-out"],
+ zIndex: 70,
+ "[data-starting-style]": {
+ opacity: 0,
+ },
+ "[data-ending-style]": {
+ opacity: 0,
+ },
+ },
+});
+
+export const part = stylex.create({
+ viewport: {
+ maxHeight: "min(24rem, var(--available-height))",
+ overflowY: "auto",
+ },
+ title: {
+ color: tokens["--dowel-text-primary"],
+ fontFamily: tokens["--dowel-font-sans"],
+ fontSize: "0.8125rem",
+ fontWeight: 550,
+ letterSpacing: "-0.0125rem",
+ lineHeight: 1.4,
+ margin: 0,
+ },
+ description: {
+ color: tokens["--dowel-text-tertiary"],
+ fontFamily: tokens["--dowel-font-sans"],
+ fontSize: "0.8125rem",
+ letterSpacing: "-0.0125rem",
+ lineHeight: 1.5,
+ marginBlock: "0.25rem 0",
+ },
+ arrow: {
+ alignItems: "center",
+ display: "flex",
+ height: "0.5rem",
+ justifyContent: "center",
+ width: "0.75rem",
+ },
+ arrowShape: {
+ backgroundColor: tokens["--dowel-bg-elevated"],
+ borderBottomColor: tokens["--dowel-border-default"],
+ borderBottomStyle: "solid",
+ borderBottomWidth: tokens["--dowel-hairline"],
+ borderRightColor: tokens["--dowel-border-default"],
+ borderRightStyle: "solid",
+ borderRightWidth: tokens["--dowel-hairline"],
+ display: "block",
+ height: "0.5rem",
+ rotate: "45deg",
+ width: "0.5rem",
+ },
+});
diff --git a/packages/dowel/src/components/popover/popover.test.tsx b/packages/dowel/src/components/popover/popover.test.tsx
new file mode 100644
index 0000000..4d484f5
--- /dev/null
+++ b/packages/dowel/src/components/popover/popover.test.tsx
@@ -0,0 +1,93 @@
+import { render, screen, waitFor } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { describe, expect, it } from "vitest";
+
+import { expectNoA11yViolations } from "../../../test/setup";
+import { ThemeProvider } from "../../theme/theme-provider";
+import { Button } from "../button";
+import { Popover } from "./index";
+
+function Example() {
+ return (
+
+ Repository access} />
+
+
+
+
+ Repository access
+
+ Visible to organization members.
+
+ Done} />
+
+
+
+
+ );
+}
+
+describe("Popover", () => {
+ it("opens from its trigger and closes from its close control", async () => {
+ render();
+ expect(screen.queryByText("Visible to organization members.")).toBeNull();
+
+ await userEvent.click(
+ screen.getByRole("button", { name: "Repository access" }),
+ );
+ expect(
+ await screen.findByText("Visible to organization members."),
+ ).toBeDefined();
+
+ await userEvent.click(screen.getByRole("button", { name: "Done" }));
+ await waitFor(() =>
+ expect(screen.queryByText("Visible to organization members.")).toBeNull(),
+ );
+ });
+
+ it("carries the active theme into its portal", async () => {
+ render(
+
+
+
+
+ Details
+
+
+
+ ,
+ );
+ await screen.findByText("Details");
+ expect(screen.getByTestId("portal").dataset.dowelTheme).toBe("dark");
+ });
+
+ it("ignores appearance props smuggled onto rendered parts", async () => {
+ const smuggled = {
+ id: "smuggled-popup",
+ className: "evil",
+ style: { color: "red" },
+ };
+ render(
+
+
+
+ Details
+
+
+ ,
+ );
+ await screen.findByText("Details");
+ const popup = document.getElementById("smuggled-popup")!;
+ expect(popup.className).not.toContain("evil");
+ expect(popup.style.color).toBe("");
+ });
+
+ it("has no accessibility violations", async () => {
+ const { container } = render();
+ await userEvent.click(
+ screen.getByRole("button", { name: "Repository access" }),
+ );
+ await screen.findByText("Visible to organization members.");
+ await expectNoA11yViolations(container);
+ });
+});
diff --git a/packages/dowel/src/components/property-picker/index.tsx b/packages/dowel/src/components/property-picker/index.tsx
index 3d40143..819fce0 100644
--- a/packages/dowel/src/components/property-picker/index.tsx
+++ b/packages/dowel/src/components/property-picker/index.tsx
@@ -1,4 +1,5 @@
import { Combobox as BaseCombobox } from "@base-ui/react/combobox";
+import { CheckIcon, MagnifyingGlassIcon } from "@heroicons/react/16/solid";
import * as stylex from "@stylexjs/stylex";
import { useContext, useMemo, useState } from "react";
import type { ReactNode } from "react";
@@ -139,17 +140,11 @@ export function PropertyPicker({
data-dowel-component="property-picker-popup"
>
-
+ width={16}
+ height={16}
+ />
- ✓
+
)}
diff --git a/packages/dowel/src/components/search-field/index.tsx b/packages/dowel/src/components/search-field/index.tsx
index 313dae1..796ad4e 100644
--- a/packages/dowel/src/components/search-field/index.tsx
+++ b/packages/dowel/src/components/search-field/index.tsx
@@ -1,4 +1,5 @@
import { Input as BaseInput } from "@base-ui/react/input";
+import { MagnifyingGlassIcon, XMarkIcon } from "@heroicons/react/16/solid";
import * as stylex from "@stylexjs/stylex";
import { forwardRef, useImperativeHandle, useRef, useState } from "react";
import type {
@@ -93,17 +94,11 @@ export const SearchField = forwardRef(
data-size={size}
data-variant={variant}
>
-
+ width={16}
+ height={16}
+ />
(
disabled={disabled}
onClick={clear}
>
-
+
) : shortcut ? (
diff --git a/packages/dowel/src/components/select/index.tsx b/packages/dowel/src/components/select/index.tsx
index 0784041..32140c4 100644
--- a/packages/dowel/src/components/select/index.tsx
+++ b/packages/dowel/src/components/select/index.tsx
@@ -1,4 +1,5 @@
import { Select as BaseSelect } from "@base-ui/react/select";
+import { CheckIcon, ChevronDownIcon } from "@heroicons/react/16/solid";
import * as stylex from "@stylexjs/stylex";
import { useContext, useMemo } from "react";
@@ -100,15 +101,7 @@ export function Select({
placeholder={placeholder}
/>
-
+
- ✓
+
))}
diff --git a/packages/dowel/src/components/separator/index.tsx b/packages/dowel/src/components/separator/index.tsx
new file mode 100644
index 0000000..b310e14
--- /dev/null
+++ b/packages/dowel/src/components/separator/index.tsx
@@ -0,0 +1,43 @@
+import * as stylex from "@stylexjs/stylex";
+import { forwardRef } from "react";
+import type { ComponentPropsWithoutRef } from "react";
+
+import { withoutAppearanceProps } from "../_shared/props";
+import * as styles from "./separator.stylex";
+
+export interface SeparatorProps
+ extends Omit<
+ ComponentPropsWithoutRef<"div">,
+ "children" | "className" | "style" | "role" | "aria-orientation"
+ > {
+ orientation?: "horizontal" | "vertical";
+ /** Decorative separators are hidden from the accessibility tree. */
+ decorative?: boolean;
+}
+
+export const Separator = forwardRef(
+ function Separator(
+ { orientation = "horizontal", decorative = true, ...props },
+ ref,
+ ) {
+ const safeProps = withoutAppearanceProps(props);
+ const resolved = stylex.props(
+ styles.root.base,
+ styles.orientation[orientation],
+ );
+
+ return (
+
+ );
+ },
+);
diff --git a/packages/dowel/src/components/separator/separator.stylex.ts b/packages/dowel/src/components/separator/separator.stylex.ts
new file mode 100644
index 0000000..44f4aaa
--- /dev/null
+++ b/packages/dowel/src/components/separator/separator.stylex.ts
@@ -0,0 +1,24 @@
+import * as stylex from "@stylexjs/stylex";
+
+import { tokens } from "../../theme/tokens.stylex";
+
+export const root = stylex.create({
+ base: {
+ backgroundColor: tokens["--dowel-border-subtle"],
+ border: 0,
+ boxSizing: "border-box",
+ flexShrink: 0,
+ },
+});
+
+export const orientation = stylex.create({
+ horizontal: {
+ height: tokens["--dowel-hairline"],
+ width: "100%",
+ },
+ vertical: {
+ alignSelf: "stretch",
+ minHeight: "1rem",
+ width: tokens["--dowel-hairline"],
+ },
+});
diff --git a/packages/dowel/src/components/separator/separator.test.tsx b/packages/dowel/src/components/separator/separator.test.tsx
new file mode 100644
index 0000000..6aff921
--- /dev/null
+++ b/packages/dowel/src/components/separator/separator.test.tsx
@@ -0,0 +1,42 @@
+import { render, screen } from "@testing-library/react";
+import { describe, expect, it } from "vitest";
+
+import { expectNoA11yViolations } from "../../../test/setup";
+import { Separator } from "./index";
+
+describe("Separator", () => {
+ it("is decorative by default", () => {
+ const { container } = render();
+ const separator = container.querySelector(
+ '[data-dowel-component="separator"]',
+ )!;
+ expect(separator.getAttribute("role")).toBe("none");
+ expect(separator.getAttribute("aria-hidden")).toBe("true");
+ });
+
+ it("supports a semantic vertical separator", () => {
+ render();
+ const separator = screen.getByRole("separator");
+ expect(separator.getAttribute("aria-orientation")).toBe("vertical");
+ expect(separator.dataset.orientation).toBe("vertical");
+ });
+
+ it("ignores appearance props smuggled through a spread", () => {
+ const smuggled = {
+ id: "separator",
+ className: "evil",
+ style: { color: "red" },
+ };
+ const { container } = render();
+ const separator = container.querySelector(
+ '[data-dowel-component="separator"]',
+ )!;
+ expect(separator.className).not.toContain("evil");
+ expect(separator.getAttribute("style")).toBeNull();
+ });
+
+ it("has no accessibility violations", async () => {
+ const { container } = render();
+ await expectNoA11yViolations(container);
+ });
+});
diff --git a/packages/dowel/src/components/skeleton/index.tsx b/packages/dowel/src/components/skeleton/index.tsx
new file mode 100644
index 0000000..45b97a8
--- /dev/null
+++ b/packages/dowel/src/components/skeleton/index.tsx
@@ -0,0 +1,39 @@
+import * as stylex from "@stylexjs/stylex";
+import { forwardRef } from "react";
+import type { ComponentPropsWithoutRef } from "react";
+
+import { withoutAppearanceProps } from "../_shared/props";
+import * as styles from "./skeleton.stylex";
+
+export type SkeletonVariant = "text" | "block" | "circle";
+export type SkeletonSize = "sm" | "md" | "lg";
+
+export interface SkeletonProps
+ extends Omit<
+ ComponentPropsWithoutRef<"span">,
+ "children" | "className" | "style"
+ > {
+ variant?: SkeletonVariant;
+ size?: SkeletonSize;
+}
+
+export const Skeleton = forwardRef(
+ function Skeleton({ variant = "text", size = "md", ...props }, ref) {
+ const safeProps = withoutAppearanceProps(props);
+ return (
+
+ );
+ },
+);
diff --git a/packages/dowel/src/components/skeleton/skeleton.stylex.ts b/packages/dowel/src/components/skeleton/skeleton.stylex.ts
new file mode 100644
index 0000000..850e835
--- /dev/null
+++ b/packages/dowel/src/components/skeleton/skeleton.stylex.ts
@@ -0,0 +1,52 @@
+import * as stylex from "@stylexjs/stylex";
+
+import { tokens } from "../../theme/tokens.stylex";
+
+const pulse = stylex.keyframes({
+ "0%, 100%": { opacity: 0.45 },
+ "50%": { opacity: 0.85 },
+});
+const REDUCED_MOTION = "@media (prefers-reduced-motion: reduce)";
+
+export const root = stylex.create({
+ base: {
+ animationDuration: "1600ms",
+ animationIterationCount: "infinite",
+ animationName: {
+ default: pulse,
+ [REDUCED_MOTION]: "none",
+ },
+ animationTimingFunction: "ease-in-out",
+ backgroundColor: tokens["--dowel-bg-surface-3"],
+ boxSizing: "border-box",
+ display: "block",
+ maxWidth: "100%",
+ },
+});
+
+export const variant = stylex.create({
+ text: { borderRadius: tokens["--dowel-radius-sm"], width: "100%" },
+ block: { borderRadius: tokens["--dowel-radius-md"], width: "100%" },
+ circle: {
+ borderRadius: tokens["--dowel-radius-pill"],
+ flexShrink: 0,
+ },
+});
+
+export const textSize = stylex.create({
+ sm: { height: "0.5rem" },
+ md: { height: "0.75rem" },
+ lg: { height: "1rem" },
+});
+
+export const blockSize = stylex.create({
+ sm: { height: "3rem" },
+ md: { height: "5rem" },
+ lg: { height: "8rem" },
+});
+
+export const circleSize = stylex.create({
+ sm: { height: "1.5rem", width: "1.5rem" },
+ md: { height: "2rem", width: "2rem" },
+ lg: { height: "2.5rem", width: "2.5rem" },
+});
diff --git a/packages/dowel/src/components/skeleton/skeleton.test.tsx b/packages/dowel/src/components/skeleton/skeleton.test.tsx
new file mode 100644
index 0000000..933176a
--- /dev/null
+++ b/packages/dowel/src/components/skeleton/skeleton.test.tsx
@@ -0,0 +1,47 @@
+import { render } from "@testing-library/react";
+import { describe, expect, it } from "vitest";
+
+import { expectNoA11yViolations } from "../../../test/setup";
+import { Skeleton } from "./index";
+
+describe("Skeleton", () => {
+ it("is hidden from the accessibility tree", () => {
+ const { container } = render();
+ const skeleton = container.querySelector(
+ '[data-dowel-component="skeleton"]',
+ )!;
+ expect(skeleton.getAttribute("aria-hidden")).toBe("true");
+ });
+
+ it("exposes its visual variant and size", () => {
+ const { container } = render();
+ const skeleton = container.querySelector(
+ '[data-dowel-component="skeleton"]',
+ )!;
+ expect(skeleton.dataset.variant).toBe("circle");
+ expect(skeleton.dataset.size).toBe("lg");
+ });
+
+ it("ignores appearance props smuggled through a spread", () => {
+ const smuggled = {
+ id: "skeleton",
+ className: "evil",
+ style: { color: "red" },
+ };
+ const { container } = render();
+ const skeleton = container.querySelector(
+ '[data-dowel-component="skeleton"]',
+ )!;
+ expect(skeleton.className).not.toContain("evil");
+ expect(skeleton.getAttribute("style")).toBeNull();
+ });
+
+ it("has no accessibility violations", async () => {
+ const { container } = render(
+ ,
+ );
+ await expectNoA11yViolations(container);
+ });
+});
diff --git a/packages/dowel/src/components/spinner/index.tsx b/packages/dowel/src/components/spinner/index.tsx
new file mode 100644
index 0000000..882e40d
--- /dev/null
+++ b/packages/dowel/src/components/spinner/index.tsx
@@ -0,0 +1,37 @@
+import * as stylex from "@stylexjs/stylex";
+import { forwardRef } from "react";
+import type { ComponentPropsWithoutRef } from "react";
+
+import { withoutAppearanceProps } from "../_shared/props";
+import * as styles from "./spinner.stylex";
+
+export interface SpinnerProps
+ extends Omit<
+ ComponentPropsWithoutRef<"span">,
+ "children" | "className" | "style" | "role"
+ > {
+ size?: "sm" | "md" | "lg";
+ label?: string;
+}
+
+export const Spinner = forwardRef(
+ function Spinner({ size = "md", label = "Loading", ...props }, ref) {
+ const safeProps = withoutAppearanceProps(props);
+ return (
+
+
+ {label}
+
+ );
+ },
+);
diff --git a/packages/dowel/src/components/spinner/spinner.stylex.ts b/packages/dowel/src/components/spinner/spinner.stylex.ts
new file mode 100644
index 0000000..d39c23e
--- /dev/null
+++ b/packages/dowel/src/components/spinner/spinner.stylex.ts
@@ -0,0 +1,56 @@
+import * as stylex from "@stylexjs/stylex";
+
+import { tokens } from "../../theme/tokens.stylex";
+
+const spin = stylex.keyframes({
+ to: { rotate: "360deg" },
+});
+const REDUCED_MOTION = "@media (prefers-reduced-motion: reduce)";
+
+export const root = stylex.create({
+ base: {
+ alignItems: "center",
+ color: tokens["--dowel-text-tertiary"],
+ display: "inline-flex",
+ flexShrink: 0,
+ justifyContent: "center",
+ },
+});
+
+export const visual = stylex.create({
+ base: {
+ animationDuration: "700ms",
+ animationIterationCount: "infinite",
+ animationName: {
+ default: spin,
+ [REDUCED_MOTION]: "none",
+ },
+ animationTimingFunction: "linear",
+ borderColor: tokens["--dowel-border-strong"],
+ borderRadius: tokens["--dowel-radius-pill"],
+ borderStyle: "solid",
+ borderTopColor: tokens["--dowel-accent"],
+ boxSizing: "border-box",
+ display: "block",
+ },
+});
+
+export const size = stylex.create({
+ sm: { borderWidth: "1.5px", height: "0.875rem", width: "0.875rem" },
+ md: { borderWidth: "1.5px", height: "1rem", width: "1rem" },
+ lg: { borderWidth: "2px", height: "1.25rem", width: "1.25rem" },
+});
+
+export const part = stylex.create({
+ visuallyHidden: {
+ border: 0,
+ clip: "rect(0 0 0 0)",
+ height: "1px",
+ margin: "-1px",
+ overflow: "hidden",
+ padding: 0,
+ position: "absolute",
+ whiteSpace: "nowrap",
+ width: "1px",
+ },
+});
diff --git a/packages/dowel/src/components/spinner/spinner.test.tsx b/packages/dowel/src/components/spinner/spinner.test.tsx
new file mode 100644
index 0000000..a748cde
--- /dev/null
+++ b/packages/dowel/src/components/spinner/spinner.test.tsx
@@ -0,0 +1,34 @@
+import { render, screen } from "@testing-library/react";
+import { describe, expect, it } from "vitest";
+
+import { expectNoA11yViolations } from "../../../test/setup";
+import { Spinner } from "./index";
+
+describe("Spinner", () => {
+ it("announces the loading label", () => {
+ render();
+ expect(screen.getByRole("status").textContent).toBe("Loading repositories");
+ });
+
+ it("exposes its size", () => {
+ render();
+ expect(screen.getByRole("status").dataset.size).toBe("lg");
+ });
+
+ it("ignores appearance props smuggled through a spread", () => {
+ const smuggled = {
+ id: "spinner",
+ className: "evil",
+ style: { color: "red" },
+ };
+ render();
+ const spinner = screen.getByRole("status");
+ expect(spinner.className).not.toContain("evil");
+ expect(spinner.getAttribute("style")).toBeNull();
+ });
+
+ it("has no accessibility violations", async () => {
+ const { container } = render();
+ await expectNoA11yViolations(container);
+ });
+});
diff --git a/packages/dowel/src/components/status/index.tsx b/packages/dowel/src/components/status/index.tsx
new file mode 100644
index 0000000..98bacb6
--- /dev/null
+++ b/packages/dowel/src/components/status/index.tsx
@@ -0,0 +1,43 @@
+import * as stylex from "@stylexjs/stylex";
+import { forwardRef } from "react";
+import type { ComponentPropsWithoutRef, ReactNode } from "react";
+
+import { withoutAppearanceProps } from "../_shared/props";
+import * as styles from "./status.stylex";
+
+export type StatusTone =
+ | "neutral"
+ | "accent"
+ | "success"
+ | "warning"
+ | "danger";
+
+export interface StatusProps
+ extends Omit, "className" | "style"> {
+ tone?: StatusTone;
+ icon?: ReactNode;
+}
+
+export const Status = forwardRef(function Status(
+ { tone = "neutral", icon, children, ...props },
+ ref,
+) {
+ const safeProps = withoutAppearanceProps(props);
+ return (
+
+
+ {icon ?? }
+
+ {children}
+
+ );
+});
diff --git a/packages/dowel/src/components/status/status.stylex.ts b/packages/dowel/src/components/status/status.stylex.ts
new file mode 100644
index 0000000..33bf0af
--- /dev/null
+++ b/packages/dowel/src/components/status/status.stylex.ts
@@ -0,0 +1,49 @@
+import * as stylex from "@stylexjs/stylex";
+
+import { tokens } from "../../theme/tokens.stylex";
+
+export const root = stylex.create({
+ base: {
+ alignItems: "center",
+ color: tokens["--dowel-text-secondary"],
+ display: "inline-flex",
+ fontFamily: tokens["--dowel-font-sans"],
+ fontSize: "0.8125rem",
+ fontWeight: 450,
+ gap: "0.375rem",
+ letterSpacing: "-0.0125rem",
+ lineHeight: 1.4,
+ maxWidth: "100%",
+ },
+});
+
+export const part = stylex.create({
+ visual: {
+ alignItems: "center",
+ display: "inline-flex",
+ flexShrink: 0,
+ height: "1rem",
+ justifyContent: "center",
+ width: "1rem",
+ },
+ dot: {
+ backgroundColor: "currentColor",
+ borderRadius: tokens["--dowel-radius-pill"],
+ height: "0.4375rem",
+ width: "0.4375rem",
+ },
+ label: {
+ minWidth: 0,
+ overflow: "hidden",
+ textOverflow: "ellipsis",
+ whiteSpace: "nowrap",
+ },
+});
+
+export const tone = stylex.create({
+ neutral: { color: tokens["--dowel-text-tertiary"] },
+ accent: { color: tokens["--dowel-accent"] },
+ success: { color: tokens["--dowel-success"] },
+ warning: { color: tokens["--dowel-warning"] },
+ danger: { color: tokens["--dowel-danger"] },
+});
diff --git a/packages/dowel/src/components/status/status.test.tsx b/packages/dowel/src/components/status/status.test.tsx
new file mode 100644
index 0000000..e0b910f
--- /dev/null
+++ b/packages/dowel/src/components/status/status.test.tsx
@@ -0,0 +1,47 @@
+import { render, screen } from "@testing-library/react";
+import { describe, expect, it } from "vitest";
+
+import { expectNoA11yViolations } from "../../../test/setup";
+import { renderBoth } from "../../../test/render";
+import { Status } from "./index";
+
+describe("Status", () => {
+ it("renders a named status without creating a live region", () => {
+ render(Operational);
+ const status = screen
+ .getByText("Operational")
+ .closest("[data-dowel-component]")!;
+ expect(status.dataset.tone).toBe("success");
+ expect(status.getAttribute("role")).toBeNull();
+ });
+
+ it("accepts an application-supplied live region role", () => {
+ render(Connected);
+ expect(screen.getByRole("status").textContent).toContain("Connected");
+ });
+
+ it("ignores appearance props smuggled through a spread", () => {
+ const smuggled = { className: "evil", style: { color: "red" } };
+ render(Operational);
+ const status = screen
+ .getByText("Operational")
+ .closest("[data-dowel-component]")!;
+ expect(status.className).not.toContain("evil");
+ expect(status.getAttribute("style")).toBeNull();
+ });
+
+ it("renders in both themes", () => {
+ const { light, dark } = renderBoth(Operational);
+ expect(
+ light.querySelector('[data-dowel-component="status"]'),
+ ).not.toBeNull();
+ expect(
+ dark.querySelector('[data-dowel-component="status"]'),
+ ).not.toBeNull();
+ });
+
+ it("has no accessibility violations", async () => {
+ const { container } = render(Degraded);
+ await expectNoA11yViolations(container);
+ });
+});
diff --git a/packages/dowel/src/index.ts b/packages/dowel/src/index.ts
index 73d055b..d11b52f 100644
--- a/packages/dowel/src/index.ts
+++ b/packages/dowel/src/index.ts
@@ -76,3 +76,25 @@ export type { SelectOption, SelectProps } from "./components/select";
export { Callout } from "./components/callout";
export type { CalloutProps, CalloutTone } from "./components/callout";
export { AlertDialog } from "./components/alert-dialog";
+export { Popover } from "./components/popover";
+export { Separator } from "./components/separator";
+export type { SeparatorProps } from "./components/separator";
+export { Avatar } from "./components/avatar";
+export type {
+ AvatarProps,
+ AvatarShape,
+ AvatarSize,
+ AvatarStatus,
+} from "./components/avatar";
+export { Status } from "./components/status";
+export type { StatusProps, StatusTone } from "./components/status";
+export { Spinner } from "./components/spinner";
+export type { SpinnerProps } from "./components/spinner";
+export { Skeleton } from "./components/skeleton";
+export type {
+ SkeletonProps,
+ SkeletonSize,
+ SkeletonVariant,
+} from "./components/skeleton";
+export { EmptyState } from "./components/empty-state";
+export type { EmptyStateProps } from "./components/empty-state";
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index d19a0dd..6d219d5 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -26,6 +26,9 @@ importers:
'@fontsource-variable/host-grotesk':
specifier: 5.3.0
version: 5.3.0
+ '@heroicons/react':
+ specifier: 2.2.0
+ version: 2.2.0(react@19.2.8)
'@karnstack/dowel':
specifier: workspace:*
version: link:../../packages/dowel
@@ -75,6 +78,9 @@ importers:
'@base-ui/react':
specifier: ^1.7.0
version: 1.7.0(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
+ '@heroicons/react':
+ specifier: 2.2.0
+ version: 2.2.0(react@19.2.8)
'@stylexjs/stylex':
specifier: 0.19.0
version: 0.19.0
@@ -636,6 +642,11 @@ packages:
'@fontsource-variable/host-grotesk@5.3.0':
resolution: {integrity: sha512-WvdqilphrdjYLeCHXny9gkdpIZgzyeHpgRWB8T4LSKs5nVnCs6pYYeCeMiRshxcuB6djqL6CSdF1Hq0BvMNCwA==}
+ '@heroicons/react@2.2.0':
+ resolution: {integrity: sha512-LMcepvRaS9LYHJGsF0zzmgKCUim/X3N/DQKc4jepAXJ7l8QxJ1PmxJzqplF2Z3FE4PqBAIGyJAQ/w4B5dsqbtQ==}
+ peerDependencies:
+ react: '>= 16 || ^19.0.0-rc'
+
'@img/colour@1.1.0':
resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==}
engines: {node: '>=18'}
@@ -2836,6 +2847,10 @@ snapshots:
'@fontsource-variable/host-grotesk@5.3.0': {}
+ '@heroicons/react@2.2.0(react@19.2.8)':
+ dependencies:
+ react: 19.2.8
+
'@img/colour@1.1.0': {}
'@img/sharp-darwin-arm64@0.35.2':