@tightknitai/block-kitchen 0.9.9 → 0.10.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.
package/README.md CHANGED
@@ -105,11 +105,15 @@ export function MyBuilderPage() {
105
105
  | `workspaceName` | `string` | no | Shown in the preview chrome to mimic a real Slack message header. |
106
106
  | `initialBlocks` | `SupportedBlock[]` | no | Starting draft. If omitted, the builder starts empty. |
107
107
  | `onChange` | `(blocks: SupportedBlock[]) => void` | no | Fires on every state change. Use this to persist the draft (URL, localStorage, etc). |
108
- | `loadChannels` | `() => Promise<{ id: string; name: string }[]>` | yes | Returns channels available to send to. The package never makes Slack API calls itself. |
109
- | `loadSendAsUserStatus` | `() => Promise<{ canSendAsUser: boolean; oauthUrl?: string }>` | yes | Whether the current user has a Slack user-token and can post as themselves. If `canSendAsUser` is false, `oauthUrl` is shown as a "Sign in with Slack" link. |
110
- | `onSend` | `(payload) => Promise<{ ok: boolean; error?: string }>` | yes | Called when the user submits the send dialog. Payload is `{ channelId, blocks, sendAsUser }`. |
111
- | `editing` | `{ onLoadMessage, onUpdate, loadRecentMessages? }` | no | Opt-in edit mode. When present, the toolbar exposes "Edit message": the user pastes a Slack message link, `onLoadMessage({ link })` returns a host-computed [editability verdict](#editing-an-existing-message-opt-in), and a successful load flips the primary action to "Update message" wired to `onUpdate`. Pass `loadRecentMessages` to add a "recent messages from this app" picker beside the paste input; the user picks a channel first (reusing `loadChannels`) and the lookup is scoped to it. Omit `editing` to keep send-only behavior. |
112
- | `loadButtonLabel` | `string` | no | Label + accessible name for the toolbar button that opens the load-message dialog (the edit-mode entry point). Defaults to `'Load message'`. Only shown when `editing` is set and no message is loaded. |
108
+ | `onValidationChange` | `(summary: ValidationSummary) => void` | no | Fires when the draft's validation verdict changes (debounced alongside the builder's own validation pass — not on every keystroke). `summary` is `{ valid, errorCount, errors }`: the exact verdict the issues chip/sheet shows, always scoped to the `message` surface. Lets a host-owned CTA gate on validity in [compose-only mode](#compose-only-mode-bring-your-own-send-flow) without re-running the validator. |
109
+ | `loadChannels` | `() => Promise<{ id: string; name: string }[]>` | grouped\* | Returns channels available to send to. The package never makes Slack API calls itself. |
110
+ | `loadSendAsUserStatus` | `() => Promise<{ canSendAsUser: boolean; oauthUrl?: string }>` | grouped\* | Whether the current user has a Slack user-token and can post as themselves. If `canSendAsUser` is false, `oauthUrl` is shown as a "Sign in with Slack" link. |
111
+ | `onSend` | `(payload) => Promise<{ ok: boolean; error?: string }>` | grouped\* | Called when the user submits the send dialog. Payload is `{ channelId, blocks, sendAsUser, extras? }` (`extras` only when `renderSendExtras` is wired). |
112
+ | `renderSendExtras` | `(ctx: SendExtrasContext) => ReactNode` | no | Renders host-defined fields inside the built-in send dialog, below the channel and identity pickers. Collect values with `ctx.setExtras(patch)`; they arrive on `onSend` as `payload.extras`. Requires the send trio. See [Extending the send dialog](#extending-the-send-dialog-custom-fields). |
113
+ | `primaryAction` | `{ label, onClick, disableWhenInvalid? }` | no | Compose-only mode only: a host-owned button in the toolbar slot where "Review & send" normally sits. `onClick` receives `{ blocks, validation, loadedMessage }` — the current draft, the same verdict `onValidationChange` reports, and the currently [loaded message](#loading-an-existing-message-opt-in) (`null` unless `loading` is configured and one is loaded). See [Keeping the CTA in the toolbar](#keeping-the-cta-in-the-toolbar-primaryaction). |
114
+ | `loading` | `{ onLoadMessage, loadRecentMessages?, loadChannels?, initialTarget?, onLoadedMessageChange? }` | no | Opt-in: [load an existing message](#loading-an-existing-message-opt-in) into the editor. Adds a "Find message" toolbar entry — the user pastes a Slack permalink (or picks a recent message) and the draft is hydrated with its blocks. Loading is a *composition* concern, so it works in **both** send mode and compose-only mode. `onLoadedMessageChange` reports the loaded target (`null` on exit) so a host-owned commitment step can stay in sync. |
115
+ | `onUpdate` | `(payload) => Promise<{ ok: boolean; error?: string }>` | no | Opt-in update-in-place mode (send mode only); sibling to `onSend`, but the payload carries the source `channel + ts`. With `loading` configured, a loaded message flips the primary action to a "Review & update" split button wired to it (`chat.update`). |
116
+ | `loadButtonLabel` | `string` | no | Label + accessible name for the toolbar button that opens the load-message dialog. Defaults to `'Find message'`. Only shown when `loading` is configured. |
113
117
  | `updateButtonLabel` | `string` | no | Label for the toolbar's primary button while a message is loaded for editing. It's a split button: clicking it updates the message in place; the menu beside it also offers "Send as a new message" (post the current blocks as new). Defaults to `'Review & update'`. |
114
118
  | `confirmUpdateLabel` | `string` | no | Label for the update dialog's final confirm button. Defaults to `'Update message'` (shows `'Updating…'` while in flight). |
115
119
  | `previewHooks` | `PreviewHooks` | no | Hooks forwarded to `slack-blocks-to-jsx`'s `<Message>` for resolving user / channel / emoji directives. |
@@ -127,17 +131,184 @@ export function MyBuilderPage() {
127
131
  | `confirmSendLabel` | `string` | no | Label for the send dialog's final confirm button. Defaults to `'Send'` (shows `'Sending…'` while in flight). |
128
132
  | `theme` | `BrandTheme \| BrandPreset` | no | Branding tokens applied to the builder chrome (toolbar, palette, popovers, dialogs). Accepts a `Partial<BrandTokens>` map and optional `light`/`dark` overrides. See [Styling](#styling) below. |
129
133
 
130
- ## Editing an existing message (opt-in)
134
+ \* The send trio — `loadChannels`, `loadSendAsUserStatus`, `onSend` — is
135
+ all-or-nothing: provide all three for the built-in send flow, or omit all
136
+ three for [compose-only mode](#compose-only-mode-bring-your-own-send-flow)
137
+ (no send button; your app owns the send flow). Wiring only some of the three
138
+ is a type error. `onUpdate` and `renderSendExtras` require the trio;
139
+ `primaryAction` requires its absence (the built-in Send/Update flow owns the
140
+ toolbar's primary slot otherwise). `loading` is mode-agnostic — it works
141
+ with or without the trio.
131
142
 
132
- By default the builder is send-only. Pass `editing` to let users load an
133
- already-posted message, edit its blocks, and dispatch a `chat.update`. The
143
+ ## Compose-only mode (bring your own send flow)
144
+
145
+ Omit the send trio entirely and the builder renders no send button — it
146
+ becomes a pure Block Kit editor. This is the right shape when composing is
147
+ one step in a flow your app owns (a wizard, an audience picker, a scheduler):
148
+ the package owns *composition* (authoring, preview, validation UX) and your
149
+ app owns *distribution* (audiences, identity, scheduling, delivery). The
150
+ only thing that crosses the boundary is the blocks array.
151
+
152
+ Mirror the draft with `onChange`, gate your own CTA with
153
+ `onValidationChange`, and hand `toSlackBlocks(draft)` to whatever comes next:
154
+
155
+ ```tsx
156
+ import { useState } from "react";
157
+ import {
158
+ BlockKitchen,
159
+ toSlackBlocks,
160
+ type SupportedBlock,
161
+ type ValidationSummary,
162
+ } from "@tightknitai/block-kitchen";
163
+
164
+ function ComposeStep({ onNext }: { onNext: (blocks: SupportedBlock[]) => void }) {
165
+ const [draft, setDraft] = useState<SupportedBlock[]>([]);
166
+ const [validation, setValidation] = useState<ValidationSummary | null>(null);
167
+
168
+ return (
169
+ <>
170
+ <BlockKitchen onChange={setDraft} onValidationChange={setValidation} />
171
+ {/* Your CTA, your copy, your placement — gated on the builder's own verdict. */}
172
+ <button
173
+ disabled={draft.length === 0 || validation?.valid === false}
174
+ onClick={() => onNext(toSlackBlocks(draft))}
175
+ >
176
+ Next: choose audience
177
+ </button>
178
+ </>
179
+ );
180
+ }
181
+ ```
182
+
183
+ Notes:
184
+
185
+ - The rest of the toolbar (Clear, View JSON, the issues chip, theme/surface
186
+ controls) is unchanged — users can still inspect problems in the issues
187
+ sheet; only the moment of commitment moves into your app.
188
+ - `onValidationChange` reports the same verdict the issues sheet displays
189
+ (validated against the `message` surface, like Send), so your CTA and the
190
+ in-builder issue count can never disagree.
191
+ - [Loading an existing message](#loading-an-existing-message-opt-in) works
192
+ here too — pass `loading` and the "Find message" entry appears. The loaded
193
+ target (channel + ts + editability verdict) reaches your app via
194
+ `primaryAction`'s `loadedMessage` context and
195
+ `loading.onLoadedMessageChange`, so your own commitment step can offer
196
+ "update in place" alongside "post as new".
197
+ - The built-in *update* flow (`onUpdate`) is unavailable in compose-only
198
+ mode: dispatching a `chat.update` is inherently bound to a channel +
199
+ timestamp + token, so it only exists alongside the send integration.
200
+
201
+ ### Keeping the CTA in the toolbar (`primaryAction`)
202
+
203
+ The example above renders its CTA outside the builder. If you want the button
204
+ to sit where "Review & send" normally sits — say a message drafter whose
205
+ primary action is "Save template" — pass `primaryAction`:
206
+
207
+ ```tsx
208
+ <BlockKitchen
209
+ primaryAction={{
210
+ label: "Save template",
211
+ onClick: ({ blocks, validation }) => openSaveModal(blocks, validation),
212
+ // Send-style gating is opt-in. Default is enabled-while-invalid: a
213
+ // drafter usually allows committing a work-in-progress draft and
214
+ // surfaces the verdict in its own flow instead.
215
+ disableWhenInvalid: false,
216
+ }}
217
+ />
218
+ ```
219
+
220
+ `onClick` receives the current draft in the builder's native format (the same
221
+ shape `onChange` reports — persist that to re-open the draft later; run it
222
+ through `toSlackBlocks` for the Slack-ready payload) plus the validation
223
+ verdict `onValidationChange` would report. The button is never disabled for
224
+ an empty draft; gate on `blocks.length` in `onClick` if you need that.
225
+
226
+ ### Building a bespoke send flow from the exported primitives
227
+
228
+ If your custom flow ends in something send-shaped, you don't have to rebuild
229
+ the built-in dialog's machinery — the send-flow primitives are exported:
230
+
231
+ - `SendDialog` — the built-in dialog as a standalone component
232
+ (`SendDialogProps`): channel loading/error states, bot-vs-user identity
233
+ picker, OAuth hand-off, in-flight and failure states. Mount it against your
234
+ own trigger.
235
+ - `useSlackSignIn(loadStatus, { open, enabled })` — the OAuth sign-in state
236
+ machine behind the identity picker: fetches user-token status on open,
237
+ refreshes on window focus, and background-polls after the OAuth tab opens
238
+ until the token appears.
239
+ - `SlackSignInButton` — the "Sign in with Slack" button with its polling
240
+ spinner, for wiring `useSlackSignIn` into your own dialog.
241
+
242
+ ## Extending the send dialog (custom fields)
243
+
244
+ When the built-in send flow is *almost* right — you want the channel picker,
245
+ identity picker, and sending states, plus a few controls of your own (a
246
+ cross-post toggle, a custom sender name) — pass `renderSendExtras` instead of
247
+ rebuilding the dialog. It renders below the built-in fields, and whatever you
248
+ collect via `setExtras` arrives on `onSend` as `payload.extras`:
249
+
250
+ ```tsx
251
+ <BlockKitchen
252
+ loadChannels={loadChannels}
253
+ loadSendAsUserStatus={loadSendAsUserStatus}
254
+ renderSendExtras={({ extras, setExtras }) => (
255
+ <>
256
+ <label>
257
+ <input
258
+ type="checkbox"
259
+ checked={Boolean(extras.alsoPostToIntranet)}
260
+ onChange={(e) => setExtras({ alsoPostToIntranet: e.target.checked })}
261
+ />
262
+ Also publish to the intranet
263
+ </label>
264
+ <label>
265
+ Custom sender name
266
+ <input
267
+ type="text"
268
+ value={String(extras.senderName ?? "")}
269
+ onChange={(e) => setExtras({ senderName: e.target.value })}
270
+ />
271
+ </label>
272
+ </>
273
+ )}
274
+ onSend={async ({ channelId, blocks, sendAsUser, extras }) => {
275
+ await api.send({ channelId, blocks, sendAsUser, ...extras }); // your code
276
+ return { ok: true };
277
+ }}
278
+ />
279
+ ```
280
+
281
+ Notes:
282
+
283
+ - The `extras` object is owned by the dialog: it resets every time the dialog
284
+ opens (alongside the channel and identity pickers), and `setExtras(patch)`
285
+ shallow-merges. Render controlled inputs from `extras`.
286
+ - The context also carries the currently selected `channelId` (`null` until
287
+ channels load) and `sendAsUser`, so extras can react to them — e.g. only
288
+ offer cross-posting for certain channels.
289
+ - `payload.extras` is present iff `renderSendExtras` is wired, so existing
290
+ `onSend` handlers never see a new key.
291
+ - The dialog renders in a portal attached to `document.body`, so CSS that
292
+ relies on ancestor selectors from your app's DOM won't reach slot content —
293
+ style it directly (inline, CSS modules, utility classes).
294
+ - For customizations that generalize something the dialog already models
295
+ (identities, channels), prefer proposing a first-class prop over routing
296
+ them through extras; the slot is for genuinely host-specific concerns.
297
+
298
+ ## Loading an existing message (opt-in)
299
+
300
+ By default the builder starts from a blank canvas. Pass `loading` to let
301
+ users load an already-posted message's blocks into the editor — paste a
302
+ permalink, or pick from a "recent messages from this app" list. Loading is a
303
+ *composition* concern, so it works in **both** send mode and
304
+ [compose-only mode](#compose-only-mode-bring-your-own-send-flow). The
134
305
  package stays integration-agnostic: it makes no Slack calls and computes
135
306
  nothing about who can edit; the host does both.
136
307
 
137
308
  ```tsx
138
309
  <BlockKitchen
139
- /* …send-only props… */
140
- editing={{
310
+ /* …send trio, or nothing (compose-only)… */
311
+ loading={{
141
312
  // Host parses the pasted permalink, fetches the message, and returns a
142
313
  // verdict. `chat.update` only edits a message authored by the calling
143
314
  // token, so the host decides: bot message → 'bot', the user's own
@@ -155,17 +326,11 @@ nothing about who can edit; the host does both.
155
326
  return { ok: true, channelId: msg.channel, channelName: msg.channelName, ts: msg.ts, blocks: msg.blocks, editableVia: 'user', ...author };
156
327
  return { ok: false, reason: 'Only messages your app or you posted can be edited.', blocks: msg.blocks };
157
328
  },
158
- // Sibling to onSend; carries the source channel + ts. `asUser` follows
159
- // the verdict's `editableVia`.
160
- onUpdate: async ({ channelId, ts, blocks, asUser }) => {
161
- await chatUpdate({ channel: channelId, ts, blocks, asUser }); // your code
162
- return { ok: true };
163
- },
164
329
  // Optional: adds a "recent messages from this app" picker beside the paste
165
- // input. The user first picks a channel (reusing `loadChannels`), then this
166
- // is called with that `channelId` so the lookup scans only one channel.
167
- // These are editable-by-construction (the app authored them), so picking one
168
- // loads it straight into edit mode (no verdict needed).
330
+ // input. The user first picks a channel, then this is called with that
331
+ // `channelId` so the lookup scans only one channel. These are
332
+ // editable-by-construction (the app authored them), so picking one loads
333
+ // it directly (no verdict needed).
169
334
  loadRecentMessages: async (channelId) => {
170
335
  const msgs = await fetchRecentAppMessages(channelId); // your code
171
336
  return msgs.map((m) => ({
@@ -177,17 +342,68 @@ nothing about who can edit; the host does both.
177
342
  label: m.preview // one-line preview shown in the picker row
178
343
  }));
179
344
  }
345
+ // In send mode the recent picker's channel list reuses the send trio's
346
+ // `loadChannels`. In compose-only mode there is no trio — pass
347
+ // `loadChannels` here too, or the picker is hidden.
180
348
  }}
181
349
  />
182
350
  ```
183
351
 
184
- - On `ok`, the builder hydrates with `blocks`, shows an edit-mode badge, locks
185
- the destination to the source channel, and fixes the post-as identity to
186
- `editableVia` (the `'user'` path reuses `loadSendAsUserStatus` for the "Sign
187
- in with Slack" gate).
352
+ - On `ok`, the builder hydrates with `blocks` and shows a loaded banner
353
+ ("References an existing message in #channel"), with the author's
354
+ name/avatar in the preview header.
188
355
  - On `{ ok: false, reason }`, the load dialog renders the reason inline and
189
356
  offers **Open as a new message instead**. Pass `blocks` on the failure
190
357
  result to hydrate the draft for that fallback.
358
+ - `loading.onLoadedMessageChange` reports the loaded target — and `null`
359
+ when the user exits it — so host state stays in sync (most useful in
360
+ compose-only mode, where your app owns what happens next). It can
361
+ **re-fire for the same `channelId` + `ts`** when the host's verdict or
362
+ metadata changed (e.g. `editableVia` flipping after a Slack sign-in), so
363
+ key host state on the full target, not `ts` alone.
364
+ - The banner's **"Switch to a new message"** exit is mode-aware: in send
365
+ mode it clears the canvas and reopens the load dialog (the edit-centric
366
+ "pick a different message" flow); in compose-only mode it only detaches
367
+ the reference and **keeps the draft** — loading seeded a composition, and
368
+ a misclick must not destroy it.
369
+
370
+ ### Updating it in place (send mode)
371
+
372
+ In send mode, pair `loading` with `onUpdate` to dispatch a `chat.update` for
373
+ the loaded message. The primary action flips to a "Review & update" split
374
+ button (the menu also offers "Send as a new message"), the destination is
375
+ locked to the source channel, and the post-as identity is fixed to the
376
+ verdict's `editableVia` (the `'user'` path reuses `loadSendAsUserStatus` for
377
+ the "Sign in with Slack" gate).
378
+
379
+ ```tsx
380
+ <BlockKitchen
381
+ /* …send trio… */
382
+ loading={{ onLoadMessage }}
383
+ // Sibling to onSend; carries the source channel + ts. `asUser` follows
384
+ // the verdict's `editableVia`.
385
+ onUpdate={async ({ channelId, ts, blocks, asUser }) => {
386
+ await chatUpdate({ channel: channelId, ts, blocks, asUser }); // your code
387
+ return { ok: true };
388
+ }}
389
+ />
390
+ ```
391
+
392
+ With `loading` alone (no `onUpdate`), a loaded message can still be posted
393
+ as a brand-new message via the regular Send flow — there's just no in-place
394
+ update. In compose-only mode `onUpdate` doesn't exist; implement your own
395
+ update against the target from `loadedMessage` / `onLoadedMessageChange`.
396
+
397
+ > **Migrating from the pre-split `editing` bundle (≤ 0.9.x):** the load
398
+ > fields move to `loading` unchanged, and `onUpdate` becomes a top-level
399
+ > prop (the `editing` wrapper is gone).
400
+ >
401
+ > ```tsx
402
+ > // Before
403
+ > <BlockKitchen editing={{ onLoadMessage, onUpdate, loadRecentMessages }} />
404
+ > // After
405
+ > <BlockKitchen loading={{ onLoadMessage, loadRecentMessages }} onUpdate={onUpdate} />
406
+ > ```
191
407
 
192
408
  ## Customizing the palette
193
409
 
@@ -225,9 +441,9 @@ Variant `id`s must be unique across the array — the drag-drop lookup keys by i
225
441
 
226
442
  ## Boundary
227
443
 
228
- The package is deliberately decoupled from any Slack SDK or backend. It does not import HTTP clients, OAuth libraries, or workspace-state systems. Everything I/O-shaped is brokered through props.
444
+ The package is deliberately decoupled from any Slack SDK or backend. It does not import HTTP clients, OAuth libraries, or workspace-state systems. Everything I/O-shaped is brokered through props — and the send flow itself is optional: see [compose-only mode](#compose-only-mode-bring-your-own-send-flow) when your app owns the moment of commitment.
229
445
 
230
- Helpers also exported:
446
+ Helpers and send-flow primitives also exported:
231
447
 
232
448
  ```ts
233
449
  import {
@@ -235,18 +451,31 @@ import {
235
451
  encodeBlocksToString, // base64url-encode a blocks array (for URL state)
236
452
  decodeBlocksFromString,
237
453
  defaultPalette, // the built-in palette — spread to customize
454
+ SendDialog, // the built-in send dialog, standalone (bespoke send flows)
455
+ useSlackSignIn, // the OAuth sign-in state machine behind the identity picker
456
+ SlackSignInButton, // the "Sign in with Slack" button + polling spinner
238
457
  } from "@tightknitai/block-kitchen";
239
458
 
240
459
  import type {
241
460
  SupportedBlock,
242
461
  SupportedBlockType,
243
462
  BlockKitchenProps,
463
+ BlockKitchenBaseProps, // shared props
464
+ BlockKitchenSendProps, // the send trio + `onUpdate` + `renderSendExtras`
465
+ LoadingConfig, // load an existing message (works in both modes)
466
+ LoadedMessage, // the loaded target: channel + ts + verdict
467
+ BlockKitchenComposeOnlyProps, // the trio explicitly absent + `primaryAction`
244
468
  PaletteSection,
245
469
  PaletteVariant,
246
470
  SendPayload,
247
471
  SendResult,
472
+ SendDialogProps,
473
+ SendExtrasContext,
474
+ PrimaryActionConfig,
475
+ PrimaryActionContext,
248
476
  ChannelOption,
249
477
  SendAsUserStatus,
478
+ ValidationSummary,
250
479
  PreviewHooks,
251
480
  } from "@tightknitai/block-kitchen";
252
481
  ```
@@ -1,5 +1,5 @@
1
- import { T as SupportedBlock } from './palette-CKP8CK5Z.cjs';
2
- export { U as SupportedBlockType } from './palette-CKP8CK5Z.cjs';
1
+ import { _ as SupportedBlock } from './palette-C8sPJuXx.cjs';
2
+ export { $ as SupportedBlockType } from './palette-C8sPJuXx.cjs';
3
3
  import 'react';
4
4
  import 'slack-web-api-client';
5
5
 
package/dist/helpers.d.ts CHANGED
@@ -1,5 +1,5 @@
1
- import { T as SupportedBlock } from './palette-CKP8CK5Z.js';
2
- export { U as SupportedBlockType } from './palette-CKP8CK5Z.js';
1
+ import { _ as SupportedBlock } from './palette-C8sPJuXx.js';
2
+ export { $ as SupportedBlockType } from './palette-C8sPJuXx.js';
3
3
  import 'react';
4
4
  import 'slack-web-api-client';
5
5