Files
captoys/potluck/.agents/skills/creating-plugins/references/block-kit.md
T
dckc 4b4ef8fea6 capsite: potluck, a fresh EmDash site (cloudflare:blog out of the box)
pnpm create emdash@latest potluck --pm pnpm --install --yes, per
https://docs.emdashcms.com/ ("Create your first EmDash site"); the
--yes defaults are cloudflare + blog. Replaces the old madmode/
starter (never committed).

Generated-by: create-emdash 1.2.0, run by Claude Opus 5.5
2026-10-07 19:57:43 -05:00

585 lines
16 KiB
Markdown

# Block Kit
Declarative JSON UI for sandboxed plugin admin pages. The host renders blocks — no plugin JavaScript runs in the browser. Inspired by Slack's Block Kit but not identical — similar concepts and naming, different block/element types and capabilities.
Trusted plugins (declared in `astro.config.ts`) can ship custom React components instead. Block Kit is for runtime-installed sandboxed plugins.
Native plugins also use Block Kit elements for [Portable Text block editing fields](./portable-text-blocks.md). Plugin CLI and registry packages cannot register Portable Text block types.
## How It Works
1. User navigates to plugin admin page
2. Admin sends `page_load` interaction to plugin's admin route
3. Plugin returns `BlockResponse` with array of blocks
4. Admin renders blocks using `BlockRenderer`
5. User interacts (button click, form submit) → interaction sent back
6. Plugin returns new blocks
```typescript
import type { BlockInteraction } from "@emdash-cms/blocks";
routes: {
admin: {
handler: async (ctx) => {
// EmDash parses the request body once and exposes it as ctx.input;
// read it directly rather than ctx.request.json() (the body is consumed).
// BlockInteraction is the discriminated union of page_load,
// block_action, and form_submit payloads.
const interaction = ctx.input as BlockInteraction;
if (interaction.type === "page_load") {
return {
blocks: [
{ type: "header", text: "My Plugin Settings" },
{
type: "form",
block_id: "settings",
fields: [
{ type: "text_input", action_id: "api_url", label: "API URL" },
{ type: "toggle", action_id: "enabled", label: "Enabled", initial_value: true },
],
submit: { label: "Save", action_id: "save" },
},
],
};
}
if (interaction.type === "form_submit" && interaction.action_id === "save") {
await ctx.kv.set("settings", interaction.values);
return {
blocks: [/* updated blocks */],
toast: { message: "Settings saved", type: "success" },
};
}
},
},
}
```
## Block Types
| Type | Description |
| ----------- | --------------------------------------------------- |
| `header` | Large bold heading |
| `section` | Text with optional accessory element |
| `divider` | Horizontal rule |
| `fields` | Two-column label/value grid |
| `table` | Data table with formatting, sorting, pagination |
| `actions` | Horizontal row of buttons and controls |
| `stats` | Dashboard metric cards with trend indicators |
| `form` | Input fields with conditional visibility and submit |
| `image` | Block-level image with alt text and optional title |
| `context` | Small muted help text |
| `columns` | 2-3 column layout with nested blocks |
| `chart` | Charts (timeseries line/bar, pie, custom ECharts) |
| `code` | Syntax-highlighted code block |
| `meter` | Progress/quota meter bar |
| `banner` | Info, warning, or error inline messages |
| `empty` | Empty state with optional command and actions |
| `accordion` | Collapsible section containing nested blocks |
## Element Types
| Type | Description |
| -------------- | --------------------------------------------------------- |
| `button` | Action button with optional confirmation dialog |
| `link` | Host-resolved navigation that does not dispatch an action |
| `text_input` | Single-line or multiline text input |
| `number_input` | Numeric input with min/max |
| `select` | Dropdown select |
| `toggle` | On/off switch |
| `secret_input` | Masked input for API keys and tokens |
| `checkbox` | Multi-select checkboxes |
| `radio` | Single-select radio buttons |
| `date_input` | Date picker |
| `combobox` | Searchable dropdown select |
| `repeater` | Array of records with scalar sub-fields |
| `media_picker` | Media-library picker that stores the asset URL |
## Block Syntax
### Header
```json
{ "type": "header", "text": "Settings" }
```
### Section
```json
{
"type": "section",
"text": "Configure your plugin settings below.",
"accessory": { "type": "button", "label": "Refresh", "action_id": "refresh" }
}
```
### Divider
```json
{ "type": "divider" }
```
### Fields
```json
{
"type": "fields",
"fields": [
{ "label": "Status", "value": "Active" },
{ "label": "Last Sync", "value": "2 hours ago" }
]
}
```
### Stats
```json
{
"type": "stats",
"items": [
{ "label": "Total", "value": "1,234", "trend": "up", "description": "+12% vs last week" },
{ "label": "Active", "value": "567" }
]
}
```
- `items` — the array key is `items`, not `stats`
- `trend` — `"up"`, `"down"`, or `"neutral"`; renders an arrow icon next to the value
- `description` — secondary line beneath the value, for context such as "+12% vs last week"
### Table
```json
{
"type": "table",
"columns": [
{ "key": "name", "label": "Name" },
{ "key": "status", "label": "Status" },
{ "key": "date", "label": "Date" }
],
"rows": [{ "name": "Item 1", "status": "Active", "date": "2025-01-01" }],
"page_action_id": "browse_items",
"empty_text": "No items yet."
}
```
- `page_action_id` — required. The admin sends it as the `block_action` id when the user sorts a column or pages through results.
- `empty_text` — shown in place of the table when `rows` is empty
- `next_cursor` — set it to render a "Load more" control
### Actions
```json
{
"type": "actions",
"elements": [
{ "type": "button", "label": "Save", "action_id": "save", "style": "primary" },
{ "type": "button", "label": "Cancel", "action_id": "cancel" }
]
}
```
### Form
```json
{
"type": "form",
"block_id": "settings",
"fields": [
{ "type": "text_input", "action_id": "name", "label": "Name" },
{ "type": "number_input", "action_id": "count", "label": "Count", "min": 0, "max": 100 },
{
"type": "select",
"action_id": "theme",
"label": "Theme",
"options": [
{ "label": "Light", "value": "light" },
{ "label": "Dark", "value": "dark" }
]
},
{ "type": "toggle", "action_id": "enabled", "label": "Enabled", "initial_value": true },
{ "type": "secret_input", "action_id": "api_key", "label": "API Key" }
],
"submit": { "label": "Save", "action_id": "save_settings" }
}
```
### Columns
```json
{
"type": "columns",
"columns": [
[
{ "type": "header", "text": "Usage" },
{ "type": "meter", "label": "Storage used", "value": 65 }
],
[
{ "type": "header", "text": "Activity" },
{ "type": "context", "text": "Last sync 2 hours ago" }
]
]
}
```
- `columns` — 2 or 3 columns, each one an array of blocks
### Chart (Timeseries)
```json
{
"type": "chart",
"config": {
"chart_type": "timeseries",
"series": [
{
"name": "Requests",
"data": [
[1709596800000, 42],
[1709600400000, 67],
[1709604000000, 53]
],
"color": "#086FFF"
},
{
"name": "Errors",
"data": [
[1709596800000, 2],
[1709600400000, 5],
[1709604000000, 1]
]
}
],
"x_axis_name": "Time",
"y_axis_name": "Count",
"style": "line",
"gradient": true,
"height": 300
}
}
```
- `series[].data` — array of `[timestamp_ms, value]` tuples
- `series[].color` — hex color (optional, auto-assigned from Kumo palette)
- `style` — `"line"` (default) or `"bar"`
- `gradient` — fill gradient beneath lines (default false)
- `height` — chart height in pixels (default 350)
### Chart (Custom)
For pie charts, gauges, or any ECharts visualization:
```json
{
"type": "chart",
"config": {
"chart_type": "custom",
"options": {
"series": [
{
"type": "pie",
"data": [
{ "value": 335, "name": "Published" },
{ "value": 234, "name": "Draft" },
{ "value": 120, "name": "Scheduled" }
]
}
]
},
"height": 300
}
}
```
- `options` — raw ECharts option object passed through to `chart.setOption()`
### Code
```json
{
"type": "code",
"code": "const greeting = \"Hello!\";\nconsole.log(greeting);",
"language": "ts"
}
```
- `language` — `"ts"`, `"tsx"`, `"jsonc"`, `"bash"`, or `"css"` (defaults to `"ts"`)
### Meter
```json
{
"type": "meter",
"label": "Storage used",
"value": 65,
"custom_value": "6.5 GB / 10 GB"
}
```
- `value` — numeric value (default range 0-100)
- `max` / `min` — custom range (defaults to 0-100)
- `custom_value` — display string instead of percentage (e.g. "750 / 1,000")
### Banner
```json
{
"type": "banner",
"title": "API key invalid",
"description": "Please check your API key in settings.",
"variant": "error"
}
```
- `variant` — `"default"` (info, default), `"alert"` (warning), or `"error"`
- At least one of `title` or `description` is required
### Empty
```json
{
"type": "empty",
"title": "No submissions",
"description": "New submissions appear here.",
"command_line": "pnpm run seed",
"size": "base",
"actions": [{ "type": "button", "action_id": "refresh", "label": "Refresh" }]
}
```
### Tabs
Use `tab` to group related blocks into labelled panels:
```json
{
"type": "tab",
"panels": [
{
"label": "General",
"blocks": [{ "type": "context", "text": "General settings" }]
}
]
}
```
### Accordion
```json
{
"type": "accordion",
"label": "Advanced settings",
"default_open": false,
"blocks": [{ "type": "context", "text": "Settings visible when expanded" }]
}
```
## Repeater and media picker elements
`repeater` and `media_picker` are admin-authoring elements, not ordinary fields in a sandboxed admin-page `form`.
`repeater` captures an array of objects. Its nested fields are limited to `text_input`, `number_input`, `select`, and `toggle`:
```typescript
{
"type": "repeater",
"action_id": "items",
"label": "Questions",
"item_label": "Question",
"fields": [
{ "type": "text_input", "action_id": "question", "label": "Question" },
{ "type": "text_input", "action_id": "answer", "label": "Answer", "multiline": true }
]
}
```
`media_picker` opens the media library and stores the selected asset's URL string:
```typescript
{
"type": "media_picker",
"action_id": "hero",
"label": "Hero image",
"mime_type_filter": "image/"
}
```
## Declarative field widgets
The admin field editor can render a plugin field widget from Block Kit elements. A schema field refers to `pluginId:widgetName`; a config-declared standard descriptor supplies `name`, `label`, compatible `fieldTypes`, and `elements`.
The field-widget renderer currently supports only these elements:
- `text_input`
- `number_input`
- `toggle`
- `select`
- `media_picker`
It stores an object keyed by each element's `action_id`. Use a `json` field for this composed value; other field types are accepted by the manifest schema but are not covered by an end-to-end save test. Other element types render an unsupported-element message.
`emdash-plugin.jsonc` accepts this field-widget definition, and the plugin CLI preserves it in the registry manifest and generated descriptor. The artifact transport is tested; the repository's browser E2E test still covers a native React field widget rather than a registry declarative widget. Verify the rendered editor and saved value for the selected elements.
## Conditional Fields
Show/hide fields based on other field values. Evaluated client-side, no round-trip.
```json
{
"type": "toggle",
"action_id": "auth_enabled",
"label": "Enable Authentication"
}
```
```json
{
"type": "secret_input",
"action_id": "api_key",
"label": "API Key",
"condition": { "field": "auth_enabled", "eq": true }
}
```
## Builder Helpers
`@emdash-cms/blocks` provides TypeScript helpers:
```typescript
import { blocks, elements } from "@emdash-cms/blocks";
const {
header,
form,
section,
stats,
timeseriesChart,
customChart,
banner: bannerBlock,
empty,
accordion,
} = blocks;
const { textInput, toggle, select, button, repeater, mediaPicker } = elements;
return {
blocks: [
header("Settings"),
form({
blockId: "settings",
fields: [
textInput("site_title", "Site Title", { initialValue: "My Site" }),
toggle("generate_sitemap", "Generate Sitemap", { initialValue: true }),
select("robots", "Default Robots", [
{ label: "Index, Follow", value: "index,follow" },
{ label: "No Index", value: "noindex,follow" },
]),
],
submit: { label: "Save", actionId: "save" },
}),
// Timeseries chart
timeseriesChart({
series: [
{
name: "Page Views",
data: [
[Date.now() - 3600000, 100],
[Date.now(), 150],
],
},
],
yAxisName: "Views",
gradient: true,
}),
// Pie chart via custom ECharts options
customChart({
options: {
series: [
{
type: "pie",
data: [
{ value: 335, name: "Published" },
{ value: 234, name: "Draft" },
],
},
],
},
}),
],
};
```
## Button Confirmations
```json
{
"type": "button",
"label": "Delete All",
"action_id": "delete_all",
"style": "danger",
"confirm": {
"title": "Are you sure?",
"text": "This cannot be undone.",
"confirm": "Delete",
"deny": "Cancel"
}
}
```
## Saved-entry panels and actions
`admin.editorPanels` and `admin.editorActions` point to private plugin routes. Panel routes return Block Kit and receive `panel_load`, `block_action`, or `form_submit`. Action routes receive `editor_action` and return only these bounded fields:
```typescript
{
toast?: { message: string; type: "success" | "error" | "info" };
refresh?: true;
navigate?: LinkTarget;
patch?: EditorDraftPatchEffect;
}
```
Use only one of `refresh`, `navigate`, or `patch`. Navigation uses the same structured target validator as `link` elements. A danger action declaration must include a confirmation dialog.
For both surfaces, `routeCtx.ui.entry` contains the host-reloaded collection, saved entry ID, content locale, and version. `routeCtx.ui.extensionId` identifies the manifest declaration. Ordinary panel load contains no draft. An explicit interaction includes selected unsaved fields only when the plugin has `admin.editor-draft:read` and the extension declares bounded `draft.read` access. `admin.editor-draft:patch` separately permits atomic whole-field `set` and `clear` proposals for fields in `draft.patch`; the host previews accepted changes without saving them.
## Links and admin locale
Use a structured link target instead of returning an admin URL:
```json
{
"type": "link",
"label": "Edit article",
"target": { "kind": "content", "collection": "posts", "id": "post-1", "locale": "ar" },
"appearance": "primary"
}
```
Targets can identify saved content, a page declared by the same plugin, the plugin's generated settings page, or an absolute external HTTP, HTTPS, or `mailto:` URL. External links open in a new tab with `noopener noreferrer`. A link has no `action_id`; use a button when the interaction must call the plugin.
Every page and widget response is validated before rendering. Responses are limited to 256 KiB, 20 nested levels, 2,000 nodes, 1,000 items per array, and 64 KiB per string. Root-relative image URLs are accepted. External images require HTTPS and either `network:request` with the hostname in the plugin manifest's `allowedHosts`, or `network:request:unrestricted`.
The admin route receives host-attested UI context separately from the interaction:
```typescript
const { locale, direction, surface } = routeCtx.ui ?? {
locale: "en",
direction: "ltr",
surface: "admin-page",
};
```
The UI locale is the administrator's active locale. It is separate from the site's default content locale in `ctx.site.locale`. Runtime page labels can use it to select localized Block Kit text; manifest navigation labels remain static.
## Toast Responses
Return a `toast` alongside blocks to show a notification:
```typescript
return {
blocks: [/* ... */],
toast: { message: "Settings saved", type: "success" }, // "success" | "error" | "info"
};
```