diff --git a/.changeset/blockkit-checklist.md b/.changeset/blockkit-checklist.md
new file mode 100644
index 000000000..8ffba75ee
--- /dev/null
+++ b/.changeset/blockkit-checklist.md
@@ -0,0 +1,5 @@
+---
+"@emdash-cms/blocks": patch
+---
+
+Adds the checklist Block Kit block for read-only setup and readiness tasks.
diff --git a/docs/src/content/docs/plugins/creating-plugins/block-kit.mdx b/docs/src/content/docs/plugins/creating-plugins/block-kit.mdx
index dd03f112e..9b419645b 100644
--- a/docs/src/content/docs/plugins/creating-plugins/block-kit.mdx
+++ b/docs/src/content/docs/plugins/creating-plugins/block-kit.mdx
@@ -92,6 +92,7 @@ The standard-format route handler takes two arguments: `routeCtx` (with `input`,
| `columns` | 2–3 column layout with nested blocks |
| `empty` | Empty-state placeholder with icon, title, description, optional command line, and action buttons |
| `accordion` | Collapsible section wrapping nested blocks |
+| `checklist` | Read-only setup/readiness checklist with per-item status and optional action buttons |
## Element types
diff --git a/packages/blocks/src/blocks/checklist.tsx b/packages/blocks/src/blocks/checklist.tsx
new file mode 100644
index 000000000..d08452ca2
--- /dev/null
+++ b/packages/blocks/src/blocks/checklist.tsx
@@ -0,0 +1,96 @@
+import { CheckCircle, Circle, WarningCircle, XCircle } from "@phosphor-icons/react";
+
+import { renderElement } from "../render-element.js";
+import type { BlockInteraction, ChecklistBlock, ChecklistItem } from "../types.js";
+import { cn } from "../utils.js";
+
+const statusConfig = {
+ pending: {
+ icon: Circle,
+ color: "text-kumo-subtle",
+ marker: "border-kumo-line bg-kumo-tint",
+ },
+ complete: {
+ icon: CheckCircle,
+ color: "text-kumo-success",
+ marker: "border-kumo-success/30 bg-kumo-success/10",
+ },
+ warning: {
+ icon: WarningCircle,
+ color: "text-kumo-warning",
+ marker: "border-kumo-warning/30 bg-kumo-warning/10",
+ },
+ error: {
+ icon: XCircle,
+ color: "text-kumo-danger",
+ marker: "border-kumo-danger/30 bg-kumo-danger/10",
+ },
+} as const;
+
+function getChecklistItemKey(item: ChecklistItem, index: number): string {
+ return [item.label, item.status, item.action?.action_id ?? "", index].join(":");
+}
+
+function ChecklistRow({
+ item,
+ onAction,
+}: {
+ item: ChecklistItem;
+ onAction: (interaction: BlockInteraction) => void;
+}) {
+ const status = statusConfig[item.status];
+ const StatusIcon = status.icon;
+
+ return (
+
+
+
+
+
+
{item.label}
+ {item.description && (
+
{item.description}
+ )}
+ {item.action &&
{renderElement(item.action, onAction)}
}
+
+
+ );
+}
+
+export function ChecklistBlockComponent({
+ block,
+ onAction,
+}: {
+ block: ChecklistBlock;
+ onAction: (interaction: BlockInteraction) => void;
+}) {
+ return (
+
+ {(block.title || block.description) && (
+
+ {block.title &&
{block.title}
}
+ {block.description && (
+
{block.description}
+ )}
+
+ )}
+
+ {block.items.map((item, i) => (
+
+ ))}
+
+
+ );
+}
diff --git a/packages/blocks/src/builders.ts b/packages/blocks/src/builders.ts
index ce71b2d02..488a0e3a2 100644
--- a/packages/blocks/src/builders.ts
+++ b/packages/blocks/src/builders.ts
@@ -7,6 +7,8 @@ import type {
CheckboxElement,
ChartBlock,
ChartSeries,
+ ChecklistBlock,
+ ChecklistItem,
CodeBlock,
ComboboxElement,
ColumnsBlock,
@@ -495,6 +497,21 @@ function accordion(opts: {
};
}
+function checklist(opts: {
+ blockId?: string;
+ items: ChecklistItem[];
+ title?: string;
+ description?: string;
+}): ChecklistBlock {
+ return {
+ type: "checklist",
+ items: opts.items,
+ ...(opts.title !== undefined && { title: opts.title }),
+ ...(opts.description !== undefined && { description: opts.description }),
+ ...(opts.blockId !== undefined && { block_id: opts.blockId }),
+ };
+}
+
// ── Exports ──────────────────────────────────────────────────────────────────
export const blocks = {
@@ -517,6 +534,7 @@ export const blocks = {
tab: tabBlock,
empty,
accordion,
+ checklist,
};
export const elements = {
diff --git a/packages/blocks/src/index.ts b/packages/blocks/src/index.ts
index dfd4910ab..954576ae1 100644
--- a/packages/blocks/src/index.ts
+++ b/packages/blocks/src/index.ts
@@ -32,6 +32,7 @@ export type {
// Block sub-types
TableColumn,
StatItem,
+ ChecklistItem,
ChartSeries,
ChartConfig,
TimeseriesChartConfig,
@@ -56,6 +57,7 @@ export type {
MeterBlock,
EmptyBlock,
AccordionBlock,
+ ChecklistBlock,
Block,
// Interactions
BlockAction,
diff --git a/packages/blocks/src/renderer.tsx b/packages/blocks/src/renderer.tsx
index 2473c3794..bc96b89a9 100644
--- a/packages/blocks/src/renderer.tsx
+++ b/packages/blocks/src/renderer.tsx
@@ -2,6 +2,7 @@ import { AccordionBlockComponent } from "./blocks/accordion.js";
import { ActionsBlockComponent } from "./blocks/actions.js";
import { BannerBlockComponent } from "./blocks/banner.js";
import { ChartBlockComponent } from "./blocks/chart.js";
+import { ChecklistBlockComponent } from "./blocks/checklist.js";
import { CodeBlockComponent } from "./blocks/code.js";
import { ColumnsBlockComponent } from "./blocks/columns.js";
import { ContextBlockComponent } from "./blocks/context.js";
@@ -59,6 +60,8 @@ function renderBlock(
return ;
case "accordion":
return ;
+ case "checklist":
+ return ;
default: {
const _exhaustive: never = block;
return null;
diff --git a/packages/blocks/src/types.ts b/packages/blocks/src/types.ts
index 5105a9734..1a35221ab 100644
--- a/packages/blocks/src/types.ts
+++ b/packages/blocks/src/types.ts
@@ -201,6 +201,13 @@ export interface StatItem {
trend?: "up" | "down" | "neutral";
}
+export interface ChecklistItem {
+ label: string;
+ status: "pending" | "complete" | "warning" | "error";
+ description?: string;
+ action?: ButtonElement;
+}
+
/** A single data series for a timeseries chart. */
export interface ChartSeries {
/** Display name shown in tooltips and legends */
@@ -364,6 +371,13 @@ export interface AccordionBlock extends BlockBase {
default_open?: boolean;
}
+export interface ChecklistBlock extends BlockBase {
+ type: "checklist";
+ items: ChecklistItem[];
+ title?: string;
+ description?: string;
+}
+
export type Block =
| HeaderBlock
| SectionBlock
@@ -382,7 +396,8 @@ export type Block =
| CodeBlock
| TabBlock
| EmptyBlock
- | AccordionBlock;
+ | AccordionBlock
+ | ChecklistBlock;
// ── Interactions ─────────────────────────────────────────────────────────────
diff --git a/packages/blocks/src/validation.ts b/packages/blocks/src/validation.ts
index 9f39163b7..da5b3bfa4 100644
--- a/packages/blocks/src/validation.ts
+++ b/packages/blocks/src/validation.ts
@@ -16,6 +16,7 @@ const BLOCK_TYPES = new Set([
"code",
"empty",
"accordion",
+ "checklist",
]);
const EMPTY_SIZES = new Set(["sm", "base", "lg"]);
@@ -44,6 +45,7 @@ const CODE_LANGUAGES = new Set(["ts", "tsx", "jsonc", "bash", "css"]);
const BUTTON_STYLES = new Set(["primary", "danger", "secondary"]);
const TREND_VALUES = new Set(["up", "down", "neutral"]);
const BANNER_VARIANTS = new Set(["default", "alert", "error"]);
+const CHECKLIST_STATUSES = new Set(["pending", "complete", "warning", "error"]);
/**
* RFC 6838-style image MIME type or image-prefix.
@@ -586,6 +588,17 @@ function validateElement(value: unknown, path: string, errors: ValidationError[]
}
}
+function validateButtonElementOnly(value: unknown, path: string, errors: ValidationError[]): void {
+ validateElement(value, path, errors);
+
+ if (isRecord(value) && value.type !== undefined && value.type !== "button") {
+ errors.push({
+ path: `${path}.type`,
+ message: "Checklist item action must be a button element",
+ });
+ }
+}
+
function validateFormField(value: unknown, path: string, errors: ValidationError[]): void {
validateElement(value, path, errors);
@@ -1197,6 +1210,59 @@ function validateBlock(value: unknown, path: string, errors: ValidationError[]):
}
break;
}
+ case "checklist": {
+ if (value.title !== undefined && typeof value.title !== "string") {
+ errors.push({
+ path: `${path}.title`,
+ message: "Field 'title' must be a string if provided",
+ });
+ }
+ if (value.description !== undefined && typeof value.description !== "string") {
+ errors.push({
+ path: `${path}.description`,
+ message: "Field 'description' must be a string if provided",
+ });
+ }
+ if (!Array.isArray(value.items)) {
+ errors.push({
+ path: `${path}.items`,
+ message: "Required field 'items' must be an array",
+ });
+ } else {
+ for (let i = 0; i < value.items.length; i++) {
+ const item = value.items[i] as unknown;
+ if (!isRecord(item)) {
+ errors.push({
+ path: `${path}.items[${i}]`,
+ message: "Checklist item must be an object",
+ });
+ continue;
+ }
+ if (typeof item.label !== "string") {
+ errors.push({
+ path: `${path}.items[${i}].label`,
+ message: "Required field 'label' must be a string",
+ });
+ }
+ if (typeof item.status !== "string" || !CHECKLIST_STATUSES.has(item.status)) {
+ errors.push({
+ path: `${path}.items[${i}].status`,
+ message: `Required field 'status' must be one of: ${[...CHECKLIST_STATUSES].join(", ")}`,
+ });
+ }
+ if (item.description !== undefined && typeof item.description !== "string") {
+ errors.push({
+ path: `${path}.items[${i}].description`,
+ message: "Field 'description' must be a string if provided",
+ });
+ }
+ if (item.action !== undefined) {
+ validateButtonElementOnly(item.action, `${path}.items[${i}].action`, errors);
+ }
+ }
+ }
+ break;
+ }
}
}
diff --git a/packages/blocks/tests/form-conditions.test.tsx b/packages/blocks/tests/form-conditions.test.tsx
index 1b3704d81..c1efff74d 100644
--- a/packages/blocks/tests/form-conditions.test.tsx
+++ b/packages/blocks/tests/form-conditions.test.tsx
@@ -188,6 +188,9 @@ vi.mock("@phosphor-icons/react", () => ({
ArrowUp: () => ,
ArrowDown: () => ,
Minus: () => ,
+ Circle: () => ,
+ CheckCircle: () => ,
+ XCircle: () => ,
Info: () => ,
Warning: () => ,
WarningCircle: () => ,
diff --git a/packages/blocks/tests/renderer.test.tsx b/packages/blocks/tests/renderer.test.tsx
index 4c06d65d9..ede672338 100644
--- a/packages/blocks/tests/renderer.test.tsx
+++ b/packages/blocks/tests/renderer.test.tsx
@@ -219,6 +219,9 @@ vi.mock("@phosphor-icons/react", () => ({
ArrowUp: () => ,
ArrowDown: () => ,
Minus: () => ,
+ Circle: () => ,
+ CheckCircle: () => ,
+ XCircle: () => ,
Info: () => ,
Warning: () => ,
WarningCircle: () => ,
@@ -533,6 +536,46 @@ describe("BlockRenderer", () => {
expect(onAction).toHaveBeenCalledWith({ type: "block_action", action_id: "ping" });
});
+ it("checklist block renders items, statuses, and item action", () => {
+ const onAction = vi.fn();
+ renderBlocks(
+ [
+ {
+ type: "checklist",
+ title: "Setup checklist",
+ description: "Finish these tasks before launch.",
+ items: [
+ { label: "Create admin", status: "complete" },
+ {
+ label: "Configure storage",
+ status: "warning",
+ description: "R2 credentials are missing.",
+ action: { type: "button", action_id: "open_storage", label: "Configure" },
+ },
+ { label: "Publish", status: "pending" },
+ { label: "DNS", status: "error" },
+ ],
+ },
+ ],
+ onAction,
+ );
+
+ expect(screen.getByText("Setup checklist")).toBeTruthy();
+ expect(screen.getByText("Finish these tasks before launch.")).toBeTruthy();
+ expect(screen.getByText("Create admin")).toBeTruthy();
+ expect(screen.getByText("R2 credentials are missing.")).toBeTruthy();
+ expect(screen.getByTestId("icon-check-circle")).toBeTruthy();
+ expect(screen.getByTestId("icon-warning-circle")).toBeTruthy();
+ expect(screen.getByTestId("icon-circle")).toBeTruthy();
+ expect(screen.getByTestId("icon-x-circle")).toBeTruthy();
+
+ fireEvent.click(screen.getByText("Configure"));
+ expect(onAction).toHaveBeenCalledWith({
+ type: "block_action",
+ action_id: "open_storage",
+ });
+ });
+
it("columns block renders blocks in columns", () => {
renderBlocks([
{
diff --git a/packages/blocks/tests/validation.test.ts b/packages/blocks/tests/validation.test.ts
index e5ac96e16..2d9be83cd 100644
--- a/packages/blocks/tests/validation.test.ts
+++ b/packages/blocks/tests/validation.test.ts
@@ -135,6 +135,28 @@ describe("validateBlocks", () => {
expect(result).toEqual({ valid: true, errors: [] });
});
+ it("checklist", () => {
+ const result = validateBlocks([
+ {
+ type: "checklist",
+ title: "Setup checklist",
+ description: "Finish these tasks before launch.",
+ items: [
+ { label: "Create admin", status: "complete" },
+ {
+ label: "Configure storage",
+ status: "warning",
+ description: "R2 credentials are missing.",
+ action: { type: "button", action_id: "storage", label: "Configure" },
+ },
+ { label: "Publish", status: "pending" },
+ { label: "DNS", status: "error" },
+ ],
+ },
+ ]);
+ expect(result).toEqual({ valid: true, errors: [] });
+ });
+
it("repeater", () => {
const result = validateBlocks([
{
@@ -501,6 +523,45 @@ describe("validateBlocks", () => {
expect(result.errors[0]!.path).toBe("blocks[0].default_open");
});
+ it("checklist missing required fields", () => {
+ const result = validateBlocks([{ type: "checklist" }]);
+ expect(result.valid).toBe(false);
+ expect(result.errors.map((e) => e.path)).toContain("blocks[0].items");
+ });
+
+ it("checklist item missing label or status", () => {
+ const result = validateBlocks([{ type: "checklist", items: [{}] }]);
+ expect(result.valid).toBe(false);
+ const paths = result.errors.map((e) => e.path);
+ expect(paths).toContain("blocks[0].items[0].label");
+ expect(paths).toContain("blocks[0].items[0].status");
+ });
+
+ it("checklist rejects invalid status", () => {
+ const result = validateBlocks([
+ { type: "checklist", items: [{ label: "DNS", status: "blocked" }] },
+ ]);
+ expect(result.valid).toBe(false);
+ expect(result.errors[0]!.path).toBe("blocks[0].items[0].status");
+ });
+
+ it("checklist action must be a button element", () => {
+ const result = validateBlocks([
+ {
+ type: "checklist",
+ items: [
+ {
+ label: "Storage",
+ status: "warning",
+ action: { type: "select", action_id: "storage", label: "Storage", options: [] },
+ },
+ ],
+ },
+ ]);
+ expect(result.valid).toBe(false);
+ expect(result.errors.map((e) => e.path)).toContain("blocks[0].items[0].action.type");
+ });
+
it("stats item missing label or value", () => {
const result = validateBlocks([
{