From 5cd4611625b0da737d641b122c3c80fc07e4c266 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 15 Aug 2026 16:51:33 +0000 Subject: [PATCH 1/4] feat: simple palette mode, Slack-marked find button, verdict callout fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Load-message dialog: - Move the not-editable callout out of the scrolling preview card and up under the PREVIEW header, so it stays put while the message scrolls. A verdict with no blocks now says "Nothing to preview." rather than leaving an empty card behind the callout. - Disable the footer's "Load message" button whenever that callout owns the next step (sign in, or open as a new message) — the host has said the message can't be loaded, so the callout's button is the way forward. A bare no-match verdict keeps the button live for a retry. Toolbar: - Swap the "Find message" icon for Slack's four-colour mark, inlined as an SVG (same 3.5 box as the icons beside it). Palette: - Rename the Rich Text "Section" variant to "Rich Text Section": simple mode lists it without its section heading, so the label carries it. - Add `paletteMode` ('advanced' default | 'simple'). Simple mode opens on a flat list of the variants flagged `basic` — no sections, no search — behind an "Advanced" link whose tooltip says what's on the other side; the same link leads back. A palette flagging no `basic` variants has no simple list to show, so it stays advanced and the link is withheld. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01UDKJELLHkb1HryHes5J843 --- README.md | 29 ++++- src/components/block-kitchen.tsx | 3 + src/components/load-message-dialog.tsx | 81 +++++++----- src/components/palette.stories.tsx | 58 ++++++++- src/components/palette.tsx | 164 +++++++++++++++++++++---- src/components/toolbar.tsx | 7 +- src/index.ts | 1 + src/lib/default-blocks.ts | 15 ++- src/lib/ui/slack-mark.tsx | 44 +++++++ src/palette.ts | 2 +- src/types.ts | 32 ++++- test/load-message-dialog.test.tsx | 67 +++++++++- test/palette-modes.test.tsx | 123 +++++++++++++++++++ 13 files changed, 563 insertions(+), 63 deletions(-) create mode 100644 src/lib/ui/slack-mark.tsx create mode 100644 test/palette-modes.test.tsx diff --git a/README.md b/README.md index 97fb3e2..dcf8772 100644 --- a/README.md +++ b/README.md @@ -120,8 +120,9 @@ export function MyBuilderPage() { | `customEmojis` | `CustomEmoji[]` | no | Workspace custom emoji (`{ name, url, alias }`) the preview resolves. Entries with a `url` render `:name:` as the workspace image; alias entries (`url: null`) fall back to their target emoji. Render-only — never serialized into the emitted Block Kit JSON. A caller-supplied `previewHooks.emoji` takes precedence. | | `palette` | `PaletteSection[]` | no | The left-hand palette of draggable variants. Defaults to `defaultPalette`. Spread it to filter, reorder, or add your own pre-configured variants — see [Customizing the palette](#customizing-the-palette). | | `disabledBlockTypes` | `SupportedBlockType[]` | no | Block types to hide from the palette without rebuilding it. Filters at the variant level — a section keeps any variants whose block types aren't disabled; sections that end up empty are dropped. Convenient when you want the default palette minus a few types (e.g. `['image', 'table']` for a text-only builder). | +| `paletteMode` | `'advanced' \| 'simple'` | no | How much of the palette the user meets first. `'advanced'` (default) renders the full sectioned palette with its search input. `'simple'` opens on a flat list of the variants flagged `basic` — in the built-in palette, just **Rich Text Section** — with no search and an "Advanced" link at the top that swaps in the full palette. Nothing is removed; it's a smaller first screen. See [Simple vs advanced palette](#simple-vs-advanced-palette). | | `defaultOpenSections` | `boolean \| string[]` | no | Which palette section headers are expanded on first paint. `true` (default) opens all sections; `false` collapses all (Slack-style); an array opens only sections whose `name` is in the list (e.g. `['Section', 'Actions']`). The palette also has a built-in search input that expands matching sections on demand. | -| `showPaletteSearch` | `boolean` | no | Whether the palette renders the quick-search input above the section list. Defaults to `true`. Set `false` for compact palettes (e.g. when you've passed a small custom `palette`) where scanning by eye is faster than typing. | +| `showPaletteSearch` | `boolean` | no | Whether the palette renders the quick-search input above the section list. Defaults to `true`. Set `false` for compact palettes (e.g. when you've passed a small custom `palette`) where scanning by eye is faster than typing. Simple mode has no search either way — it appears with the rest of the palette behind "Advanced". | | `paletteSearchPlaceholder` | `string` | no | Placeholder text for the palette search input. Defaults to `'Search blocks…'`. Useful for localization. | | `allowedSurfaces` | `PreviewSurface[]` | no | Allowlist of preview surfaces (`'message'`, `'modal'`, `'app_home'`). Defaults to `['message']` — surface dropdown is hidden when only one surface is allowed. The first entry is the initial selection. | | `showThemeControl` | `boolean` | no | Defaults to `true`. When `false`, the toolbar's light/dark toggle is hidden and the theme stays at `defaultPreviewTheme`. Ignored when `previewTheme` is set (a controlled theme always hides the toggle). | @@ -452,6 +453,31 @@ const PALETTE: readonly PaletteSection[] = [ Variant `id`s must be unique across the array — the drag-drop lookup keys by id. +### Simple vs advanced palette + +By default the palette opens on everything it has: every section, plus the search input. For consumers whose users are writing ordinary messages rather than building interactive apps, `paletteMode="simple"` opens on a one-block starter list instead — no sections, no search — behind an **Advanced** link that swaps in the full palette (and back). + +```tsx + +``` + +Which variants the simple list holds is a property of the palette, not the flag: a variant opts in with `basic: true`. The built-in palette flags exactly one — **Rich Text Section** — so a custom palette that wants its own starter block says so: + +```tsx +const PALETTE: readonly PaletteSection[] = [ + { + name: "Company presets", + icon: AlignLeft, + variants: [ + { id: "help_footer", label: "Help footer", basic: true, factory: () => ({ ... }) }, + { id: "company_divider", label: "Company divider", factory: () => ({ ... }) }, + ], + }, +]; +``` + +A palette that flags none of its variants `basic` has no simple list to show, so it renders as `'advanced'` regardless — the link is withheld rather than leading to an empty rail. + ## Boundary 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. @@ -480,6 +506,7 @@ import type { BlockKitchenComposeOnlyProps, // the trio explicitly absent + `primaryAction` PaletteSection, PaletteVariant, + PaletteMode, // 'advanced' (default) | 'simple' SendPayload, SendResult, SendDialogProps, diff --git a/src/components/block-kitchen.tsx b/src/components/block-kitchen.tsx index 22e58fe..dd2ee4e 100644 --- a/src/components/block-kitchen.tsx +++ b/src/components/block-kitchen.tsx @@ -101,6 +101,7 @@ export function BlockKitchen(props: BlockKitchenProps) { confirmUpdateLabel, palette, disabledBlockTypes, + paletteMode, showPaletteSearch, paletteSearchPlaceholder, defaultOpenSections, @@ -558,6 +559,7 @@ export function BlockKitchen(props: BlockKitchenProps) { addBlock(block)} sections={paletteSections} + mode={paletteMode} showSearch={showPaletteSearch} searchPlaceholder={paletteSearchPlaceholder} defaultOpenSections={defaultOpenSections} @@ -613,6 +615,7 @@ export function BlockKitchen(props: BlockKitchenProps) { setPaletteOpen(false); }} sections={paletteSections} + mode={paletteMode} showSearch={showPaletteSearch} searchPlaceholder={paletteSearchPlaceholder} defaultOpenSections={defaultOpenSections} diff --git a/src/components/load-message-dialog.tsx b/src/components/load-message-dialog.tsx index 4735b1f..380bda1 100644 --- a/src/components/load-message-dialog.tsx +++ b/src/components/load-message-dialog.tsx @@ -132,9 +132,11 @@ type PreviewState = * can see rather than a link they can't. * * On a successful load the parent flips into edit mode (`onLoaded`); on a - * not-editable verdict the preview pane renders the host's `reason` and, when - * that verdict carries blocks, previews them alongside "Open as a new message - * instead" (a no-match verdict has none, so only the reason shows). + * not-editable verdict a callout under the preview header renders the host's + * `reason` and, when that verdict carries blocks, previews them alongside + * "Open as a new message instead" (a no-match verdict has none, so only the + * reason shows). Whenever that callout carries an action of its own, the + * footer's load button goes disabled — the callout is the way forward. * * The package never parses the permalink — the host extracts `channel + ts`. * @param props - dialog props @@ -452,7 +454,16 @@ export function LoadMessageDialog({ ? { kind: 'ready', target: linkStatus.result } : linkStatus; - const canLoad = activeTab === 'recent' ? !!selectedRecent : !!link.trim(); + // A not-editable verdict that carries its own next step — "Sign in with + // Slack", or "Open as a new message instead" — owns the flow from here: the + // host has already said this message can't be loaded, so the footer button + // goes disabled and the callout's button is the only way forward. A bare + // "no match" verdict keeps it enabled; there's nothing else to click, and + // re-submitting the same link is a legitimate retry. + const verdictOwnsAction = + linkStatus.kind === 'not-editable' && (isSafeHref(linkStatus.oauthUrl) || (linkStatus.blocks?.length ?? 0) > 0); + + const canLoad = activeTab === 'recent' ? !!selectedRecent : !!link.trim() && !verdictOwnsAction; // Only offer the tabpanel wiring when there is a tab strip to label it. const panelProps = (id: TabId) => @@ -767,7 +778,8 @@ function TabStrip({ * Right-hand pane: a full render of the selected message, scrolling on its * own so a long message can't move the dialog's footer. Loading, empty, and * failure states are handled here and here only — the left pane's controls - * stay usable whatever the preview is showing. + * stay usable whatever the preview is showing. A not-editable verdict's + * callout sits above the scrolling card, directly under the pane header. * @param props - pane props * @param props.state - what to render for the active tab * @param props.hooks - directive hooks forwarded to the block renderer @@ -816,6 +828,34 @@ function PreviewPane({ ) : null} + {/* The verdict callout sits between the header and the preview card + rather than inside it: it's a statement about the message and the + user's way out of it, not part of the message being previewed, so it + stays put while the card below scrolls. */} + {state.kind === 'not-editable' && ( +
+
+ + {state.reason} +
+ {/* Sign-in verdict: open OAuth and re-check the load on completion. */} + {signInUrl && onSignIn(signInUrl)} polling={signInPolling} />} + {/* Only offer "open as new" when there are blocks to carry over. + A no-match verdict has none, so there's nothing to open. */} + {verdictBlocks && verdictBlocks.length > 0 && ( + + )} +
+ )} + {/* Focusable so the pane is scrollable by keyboard: a plain message preview holds no controls, so without this there'd be nothing to tab to inside it and its overflow would be unreachable. */} @@ -826,30 +866,6 @@ function PreviewPane({ tabIndex={0} className="flex flex-1 flex-col gap-3 overflow-y-auto rounded-md border bg-muted/40 p-3 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring max-lg:max-h-72 max-lg:min-h-32 lg:min-h-0" > - {state.kind === 'not-editable' && ( -
-
- - {state.reason} -
- {/* Sign-in verdict: open OAuth and re-check the load on completion. */} - {signInUrl && onSignIn(signInUrl)} polling={signInPolling} />} - {/* Only offer "open as new" when there are blocks to carry over. - A no-match verdict has none, so there's nothing to open. */} - {verdictBlocks && verdictBlocks.length > 0 && ( - - )} -
- )} - {state.kind === 'error' && (

{state.error} @@ -865,6 +881,13 @@ function PreviewPane({ {state.kind === 'empty' &&

{emptyHint}

} + {/* A verdict with no blocks (no message matched the link) leaves the + card with nothing to render, and the callout above already carries + the reason — so say why it's blank rather than showing an empty box. */} + {state.kind === 'not-editable' && !(verdictBlocks && verdictBlocks.length > 0) && ( +

Nothing to preview.

+ )} + {/* `shrink-0` on the renders below is load-bearing: the message frame clips its own overflow (for the rounded corners), so as a shrinkable flex item it would compress to the pane's height and swallow the diff --git a/src/components/palette.stories.tsx b/src/components/palette.stories.tsx index e21673b..7fe5486 100644 --- a/src/components/palette.stories.tsx +++ b/src/components/palette.stories.tsx @@ -1,7 +1,7 @@ import { DndContext } from '@dnd-kit/core'; import type { Meta, StoryObj } from '@storybook/react-vite'; import { AlignLeft } from 'lucide-react'; -import { expect, fn, userEvent, within } from 'storybook/test'; +import { expect, fn, screen, userEvent, within } from 'storybook/test'; import { defaultPalette, type PaletteSection } from '../lib/default-blocks'; import { Palette } from './palette'; @@ -156,3 +156,59 @@ export const CustomSearchPlaceholder: Story = { await canvas.findByRole('searchbox', { name: /find a block/i }); } }; + +export const SimpleMode: Story = { + args: { + mode: 'simple' + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + // One block, flat — no section headings, no search. + await canvas.findByRole('button', { name: /^add rich text section to preview$/i }); + expect(canvas.queryByRole('searchbox')).toBeNull(); + expect(canvas.queryByRole('button', { name: /^add divider to preview$/i })).toBeNull(); + expect(canvas.queryByRole('button', { name: /^structure$/i })).toBeNull(); + } +}; + +export const SimpleModeAdvancedLink: Story = { + args: { + mode: 'simple' + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const link = await canvas.findByRole('button', { name: /^advanced block palette$/i }); + + // Hovering explains what's behind the link before the user commits to it. + // The tooltip portals out of the canvas, so it's queried on the document. + await userEvent.hover(link); + const help = await screen.findByText(/browse every slack block type/i); + expect(help).toBeTruthy(); + await userEvent.unhover(link); + + // Clicking it swaps in the full palette, search and all. + await userEvent.click(link); + await canvas.findByRole('searchbox', { name: /search blocks/i }); + await canvas.findByRole('button', { name: /^add divider to preview$/i }); + + // ...and the same link leads back. + await userEvent.click(await canvas.findByRole('button', { name: /^basic block palette$/i })); + expect(canvas.queryByRole('searchbox')).toBeNull(); + await canvas.findByRole('button', { name: /^add rich text section to preview$/i }); + } +}; + +export const SimpleModeWithoutBasicVariants: Story = { + args: { + mode: 'simple', + // No variant here opts into the simple list, so there's nothing to show + // in it — the palette renders the full list instead of an empty rail. + sections: CUSTOM_PALETTE + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await canvas.findByRole('button', { name: /^add help footer to preview$/i }); + await canvas.findByRole('searchbox', { name: /search blocks/i }); + expect(canvas.queryByRole('button', { name: /^advanced block palette$/i })).toBeNull(); + } +}; diff --git a/src/components/palette.tsx b/src/components/palette.tsx index eabf92b..53241fb 100644 --- a/src/components/palette.tsx +++ b/src/components/palette.tsx @@ -5,7 +5,8 @@ import { useMemo, useState } from 'react'; import { cn } from '../lib/cn'; import type { PaletteSection as PaletteSectionDef, PaletteVariant } from '../lib/default-blocks'; import { Input } from '../lib/ui/input'; -import type { SupportedBlock } from '../types'; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../lib/ui/tooltip'; +import type { PaletteMode, SupportedBlock } from '../types'; /** * The DnD draggable id format for palette items, e.g. `palette:section_mrkdwn`. @@ -45,6 +46,32 @@ function isDefaultOpen(sectionName: string, config: DefaultOpenSections): boolea return config.includes(sectionName); } +/** + * Copy for the mode link's tooltip. The simple palette is deliberately a + * dead end — one block, no search — so the link has to say what's behind it. + */ +const ADVANCED_HELP = + 'Browse every Slack block type — sections, images, buttons, inputs, tables and more — plus a search box.'; + +/** Counterpart for the link once the full palette is showing. */ +const BASIC_HELP = 'Go back to the short list: just the rich text block for writing a message.'; + +/** + * The variants simple mode offers, flattened out of their sections: it's a + * short starter list, so section headings would be chrome around one row. + */ +function basicVariants(sections: readonly PaletteSectionDef[]): PaletteVariant[] { + const out: PaletteVariant[] = []; + for (const section of sections) { + for (const variant of section.variants) { + if (variant.basic) { + out.push(variant); + } + } + } + return out; +} + function filterSections(sections: readonly PaletteSectionDef[], query: string): readonly PaletteSectionDef[] { const q = query.trim().toLowerCase(); if (q.length === 0) return sections; @@ -71,6 +98,12 @@ function filterSections(sections: readonly PaletteSectionDef[], query: string): * search input at the top filters variants by label across all sections; * an active query temporarily expands every matching section regardless * of its collapsed state. + * + * With `mode="simple"` the palette instead opens on a flat list of the + * variants flagged `basic` — no sections, no search — behind an "Advanced" + * link that swaps in the full palette described above. That keeps the first + * screen to the one block most messages are made of, without hiding the + * rest from anyone who goes looking. * @param props - palette props * @param props.onAddBlock - called when a palette item is added via its * chevron button (appends the block to the bottom of the preview) @@ -81,9 +114,14 @@ function filterSections(sections: readonly PaletteSectionDef[], query: string): * `true` (all open). * @param props.showSearch - whether the quick-search input is rendered * above the section list. Defaults to `true`. Set `false` for compact - * palettes where the list is short enough to scan by eye. + * palettes where the list is short enough to scan by eye. Simple mode + * never shows it, whatever this says. * @param props.searchPlaceholder - placeholder text shown in the search * input. Defaults to `'Search blocks…'`. Useful for localization. + * @param props.mode - `'advanced'` (default) renders the full sectioned + * palette straight away. `'simple'` starts on a flat list of the + * variants flagged `basic`, with no search and an "Advanced" link at the + * top that swaps in the full palette. See {@link PaletteMode}. * @returns the rendered palette aside */ export function Palette({ @@ -92,6 +130,7 @@ export function Palette({ defaultOpenSections = true, showSearch = true, searchPlaceholder = 'Search blocks…', + mode = 'advanced', variant = 'aside' }: { onAddBlock: (block: SupportedBlock) => void; @@ -99,6 +138,7 @@ export function Palette({ defaultOpenSections?: DefaultOpenSections; showSearch?: boolean; searchPlaceholder?: string; + mode?: PaletteMode; /** * `'aside'` — persistent left rail (default). Fixed width with right border. * `'sheet'` — full-width content for mobile bottom-sheet hosting. No @@ -107,11 +147,24 @@ export function Palette({ variant?: 'aside' | 'sheet'; }) { const [query, setQuery] = useState(''); + // Which side of the simple/advanced switch we're on. Only consulted when + // the palette offers the switch at all; a mode-less palette is always + // advanced, so the initial `false` never surfaces there. + const [advancedOpen, setAdvancedOpen] = useState(false); + + const basics = useMemo(() => (mode === 'simple' ? basicVariants(sections) : []), [mode, sections]); + // A simple mode with nothing in it would render an empty rail, so a palette + // that flags no `basic` variants (a fully custom one, say) stays advanced — + // the switch is withheld rather than leading somewhere blank. + const offersSimple = mode === 'simple' && basics.length > 0; + const advanced = !offersSimple || advancedOpen; + + const searchVisible = showSearch && advanced; const visibleSections = useMemo( - () => (showSearch ? filterSections(sections, query) : sections), - [sections, query, showSearch] + () => (searchVisible ? filterSections(sections, query) : sections), + [sections, query, searchVisible] ); - const queryActive = showSearch && query.trim().length > 0; + const queryActive = searchVisible && query.trim().length > 0; const isSheet = variant === 'sheet'; return ( @@ -121,33 +174,52 @@ export function Palette({ isSheet ? 'w-full flex-1 bg-background' : 'w-72 shrink-0 border-r bg-muted/20' )} > - {showSearch ? ( + {offersSimple || searchVisible ? (
-
- - setQuery(e.target.value)} - placeholder={searchPlaceholder} - aria-label={searchPlaceholder} - className={cn(isSheet ? 'h-10 pl-8 text-base' : 'h-8 pl-7 text-sm')} + {offersSimple ? ( + { + // Leaving advanced drops the query with it: it filters a list + // that's no longer on screen, and coming back to a palette + // pre-filtered by something typed minutes ago reads as a bug. + setQuery(''); + setAdvancedOpen((v) => !v); + }} /> -
+ ) : null} + {searchVisible ? ( +
+ + setQuery(e.target.value)} + placeholder={searchPlaceholder} + aria-label={searchPlaceholder} + className={cn(isSheet ? 'h-10 pl-8 text-base' : 'h-8 pl-7 text-sm')} + /> +
+ ) : null}
) : null}
- {visibleSections.length === 0 ? ( + {!advanced ? ( + // Simple mode: a flat list, no section headings — with this few + // rows the headings would outnumber the blocks under them. + basics.map((v) => onAddBlock(v.factory())} mode={variant} />) + ) : visibleSections.length === 0 ? (

No blocks match.

) : ( visibleSections.map((section) => ( @@ -166,6 +238,52 @@ export function Palette({ ); } +/** + * The simple/advanced switch: a text link at the top of the palette, with + * the explanation of what's on the other side hung off it as a tooltip + * (which Radix also wires up as the link's accessible description, so it + * reaches keyboard and screen-reader users, not just a hovering mouse). + * + * Carries its own `TooltipProvider` so the palette works standalone — inside + * the builder it just nests harmlessly under the one `BlockKitchen` mounts. + * @param props - link props + * @param props.advanced - whether the full palette is currently showing + * @param props.isSheet - whether the palette is rendering in the mobile sheet + * @param props.onToggle - flips between simple and advanced + * @returns the rendered mode link + */ +function ModeLink({ advanced, isSheet, onToggle }: { advanced: boolean; isSheet: boolean; onToggle: () => void }) { + return ( + +
+ + + + + + {advanced ? BASIC_HELP : ADVANCED_HELP} + + +
+
+ ); +} + /** * One collapsible category in the palette. Owns its own open/closed * state, seeded from `defaultOpen`. When `forceOpen` is true (an active diff --git a/src/components/toolbar.tsx b/src/components/toolbar.tsx index bc8a824..e5247ee 100644 --- a/src/components/toolbar.tsx +++ b/src/components/toolbar.tsx @@ -7,7 +7,6 @@ import { Code2, ExternalLink, Home, - MailSearch, MessageSquare, Moon, Pencil, @@ -24,6 +23,7 @@ import { useRef, useState } from 'react'; import { cn } from '../lib/cn'; import { Button } from '../lib/ui/button'; import { Popover, PopoverContent, PopoverTrigger } from '../lib/ui/popover'; +import { SlackMark } from '../lib/ui/slack-mark'; import type { PreviewSurface, PreviewTheme } from '../types'; const THEME_OPTIONS: { @@ -211,7 +211,10 @@ export function Toolbar({
{loadEnabled ? ( ) : null} diff --git a/src/index.ts b/src/index.ts index c1dcf1e..9275cc2 100644 --- a/src/index.ts +++ b/src/index.ts @@ -50,6 +50,7 @@ export type { LoadMessageInput, LoadResult, MarkdownBlock, + PaletteMode, PieChart, PieChartSegment, PlanBlock, diff --git a/src/lib/default-blocks.ts b/src/lib/default-blocks.ts index 6fceff3..0ad4bb1 100644 --- a/src/lib/default-blocks.ts +++ b/src/lib/default-blocks.ts @@ -29,6 +29,16 @@ export interface PaletteVariant { label: string; /** Builds a fresh block payload when this variant is dropped. */ factory: () => SupportedBlock; + /** + * Whether this variant belongs to the palette's simple mode — the short, + * flat starter list shown before the user opens "Advanced" (see + * `paletteMode` on `BlockKitchen`). Defaults to `false`: a variant is + * advanced-only unless it opts in. + * + * Only consulted when simple mode is enabled. A palette with no `basic` + * variants at all can't offer one, so it stays in advanced mode. + */ + basic?: boolean; } /** @@ -793,7 +803,10 @@ export const defaultPalette: readonly PaletteSection[] = [ variants: [ { id: 'rich_text_section', - label: 'Section', + label: 'Rich Text Section', + // The one variant simple mode offers, where it's listed without its + // section heading — hence the self-describing label. + basic: true, factory: () => ({ type: 'rich_text', elements: [ diff --git a/src/lib/ui/slack-mark.tsx b/src/lib/ui/slack-mark.tsx new file mode 100644 index 0000000..c5c2ef6 --- /dev/null +++ b/src/lib/ui/slack-mark.tsx @@ -0,0 +1,44 @@ +/** + * Slack's four-colour mark, inlined as an SVG so it needs no asset pipeline, + * no network fetch, and no extra dependency. + * + * Unlike the lucide icons it sits beside, the brand colours are baked in — + * the mark is only recognisable in its own palette, so it deliberately + * ignores `currentColor`. Size it through `className` (`h-3.5 w-3.5` etc.) + * exactly like a lucide icon. + * + * Decorative by default: callers pair it with a text label or an `aria-label` + * on the surrounding control, so it's hidden from assistive tech rather than + * announced twice. + * @param props - icon props + * @param props.className - sizing / layout classes for the `svg` element + * @returns the rendered Slack mark + */ +export function SlackMark({ className }: { className?: string }) { + return ( + + ); +} diff --git a/src/palette.ts b/src/palette.ts index e778305..f11e39e 100644 --- a/src/palette.ts +++ b/src/palette.ts @@ -13,4 +13,4 @@ export { extraAlertVariant, legacyInputVariants } from './lib/default-blocks'; -export type { SupportedBlock, SupportedBlockType } from './types'; +export type { PaletteMode, SupportedBlock, SupportedBlockType } from './types'; diff --git a/src/types.ts b/src/types.ts index a971909..42485bc 100644 --- a/src/types.ts +++ b/src/types.ts @@ -844,6 +844,22 @@ export type PreviewTheme = 'light' | 'dark'; */ export type PreviewSurface = 'message' | 'modal' | 'app_home'; +/** + * How much of the block palette the user meets first. + * - `'advanced'` (default) — the full sectioned palette with its search + * input, exactly as it has always rendered. + * - `'simple'` — opens on a flat list of the variants whose + * `PaletteVariant.basic` flag is set (in the built-in palette, just + * **Rich Text Section**) with no search, behind an "Advanced" link that + * swaps in the full palette. Nothing is removed — it's a smaller first + * screen for people writing an ordinary message, not a reduced feature + * set. + * + * A palette whose variants flag none of themselves `basic` has no simple + * list to show, so it renders as `'advanced'` regardless. + */ +export type PaletteMode = 'advanced' | 'simple'; + /** * A reusable block layout the user can apply as a starting point. * Consumed by the standalone `` component, which renders @@ -991,11 +1007,25 @@ export interface BlockKitchenBaseProps { * a custom `palette` array instead. */ disabledBlockTypes?: readonly SupportedBlockType[]; + /** + * How much of the palette the user meets first. Defaults to + * `'advanced'` — the full sectioned palette, unchanged. Pass + * `'simple'` to open on a one-block starter list (**Rich Text + * Section**) with an "Advanced" link that swaps in the full palette; + * see {@link PaletteMode}. + * + * Which variants the simple list holds is a property of the palette, + * not of this flag: a custom `palette` opts its own variants in by + * setting `basic: true` on them, and one that opts none in renders as + * `'advanced'`. + */ + paletteMode?: PaletteMode; /** * Whether the palette renders a quick-search input above the section * list. Defaults to `true`. Set `false` for compact palettes (e.g. * when you've passed a small custom `palette`) where scanning by eye - * is faster than typing. + * is faster than typing. Simple mode has no search either way — the + * search appears with the rest of the palette behind "Advanced". */ showPaletteSearch?: boolean; /** diff --git a/test/load-message-dialog.test.tsx b/test/load-message-dialog.test.tsx index 68e094a..5e79263 100644 --- a/test/load-message-dialog.test.tsx +++ b/test/load-message-dialog.test.tsx @@ -311,9 +311,15 @@ describe('LoadMessageDialog preview pane', () => { const reason = await screen.findByText("Can't edit this one."); const pane = previewPane(); - expect(pane.contains(reason)).toBe(true); + // The verdict and its escape hatch are chrome *about* the message, so they + // sit between the pane header and the scrolling card... + expect(pane.contains(reason)).toBe(false); + const header = screen.getByText('Preview'); + expect(header.compareDocumentPosition(reason) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); + expect(reason.compareDocumentPosition(pane) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); + expect(pane.contains(screen.getByRole('button', { name: 'Open as a new message instead' }))).toBe(false); + // ...while the message the verdict carried still renders inside the card. expect(within(pane).getByText('Someone else’s message')).toBeTruthy(); - expect(within(pane).getByRole('button', { name: 'Open as a new message instead' })).toBeTruthy(); }); }); @@ -340,13 +346,66 @@ describe('LoadMessageDialog not-editable verdict', () => { it('keeps the link controls usable while the preview shows the failure', async () => { await loadViaLink(async () => ({ ok: false, reason: 'No message matched that link.' })); - // The verdict lands in the preview pane... - expect(within(await screen.findByRole('region', { name: 'Message preview' })).getByText(/No message matched/)); + // The verdict lands in the right-hand pane, above the (now empty) card... + const reason = await screen.findByText(/No message matched/); + const pane = screen.getByRole('region', { name: 'Message preview' }); + expect(pane.contains(reason)).toBe(false); + expect(within(pane).getByText('Nothing to preview.')).toBeTruthy(); // ...and the left pane still holds the link the user typed, ready to fix. expect((screen.getByLabelText('Message link') as HTMLInputElement).value).toBe( 'https://x.slack.com/archives/C1/p1' ); }); + + // A verdict the user can act on owns the next step: the callout's own button + // is the way forward, so the footer's load button gets out of the way. + it('disables the load button when the verdict offers "open as new"', async () => { + await loadViaLink(async () => ({ + ok: false, + reason: 'This message was posted by someone else.', + blocks: [{ type: 'divider' }] + })); + await screen.findByRole('button', { name: 'Open as a new message instead' }); + expect((screen.getByRole('button', { name: 'Load message' }) as HTMLButtonElement).disabled).toBe(true); + // The callout's own button stays live — that's the whole point. + expect((screen.getByRole('button', { name: 'Open as a new message instead' }) as HTMLButtonElement).disabled).toBe( + false + ); + }); + + it('disables the load button when the verdict asks the user to sign in', async () => { + await loadViaLink(async () => ({ + ok: false, + reason: 'Connect your Slack account to edit your own messages.', + oauthUrl: 'https://slack.com/oauth' + })); + await screen.findByRole('button', { name: /sign in with slack/i }); + expect((screen.getByRole('button', { name: 'Load message' }) as HTMLButtonElement).disabled).toBe(true); + }); + + it('keeps the load button live for a bare no-match verdict, so the link can be retried', async () => { + const calls: string[] = []; + renderDialog({ + onLoadMessage: async ({ link }) => { + calls.push(link); + return { ok: false, reason: 'No message matched that link.' }; + } + }); + await openLinkTab(); + fireEvent.change(await screen.findByLabelText('Message link'), { + target: { value: 'https://x.slack.com/archives/C1/p1' } + }); + fireEvent.click(screen.getByRole('button', { name: 'Load message' })); + await screen.findByText('No message matched that link.'); + + // Nothing else to click, so re-submitting the same link is the retry. + const load = screen.getByRole('button', { name: 'Load message' }) as HTMLButtonElement; + expect(load.disabled).toBe(false); + await act(async () => { + fireEvent.click(load); + }); + expect(calls).toHaveLength(2); + }); }); describe('LoadMessageDialog sign-in verdict', () => { diff --git a/test/palette-modes.test.tsx b/test/palette-modes.test.tsx new file mode 100644 index 0000000..0631d8f --- /dev/null +++ b/test/palette-modes.test.tsx @@ -0,0 +1,123 @@ +/** + * Simple vs advanced palette (`paletteMode`). The palette's default — the + * full sectioned list with its search box — is the behaviour every other + * test already assumes, so what's pinned here is the opt-in simple mode: + * what it hides, what it still offers, and that "Advanced" gets all of it + * back without a remount. + */ +import { fireEvent, render, screen } from '@testing-library/react'; +import { AlignLeft } from 'lucide-react'; +import { expect, it, vi } from 'vitest'; +import { BlockKitchen } from '../src/components/block-kitchen'; +import type { PaletteSection } from '../src/lib/default-blocks'; +import type { SupportedBlock } from '../src/types'; + +/** The blocks array from the most recent onChange call. */ +function latest(onChange: ReturnType): SupportedBlock[] { + return onChange.mock.calls.at(-1)?.[0] as SupportedBlock[]; +} + +it('renders the full palette with its search box by default', () => { + render(); + expect(screen.getByRole('searchbox')).toBeTruthy(); + expect(screen.getByRole('button', { name: 'Add Divider to preview' })).toBeTruthy(); + // No switch to offer: this palette is already showing everything. + expect(screen.queryByRole('button', { name: 'Advanced block palette' })).toBeNull(); +}); + +it('offers only the basic block, with no search, in simple mode', () => { + render(); + + expect(screen.getByRole('button', { name: 'Add Rich Text Section to preview' })).toBeTruthy(); + expect(screen.queryByRole('searchbox')).toBeNull(); + // Neither the other variants nor the section headings they sit under. + expect(screen.queryByRole('button', { name: 'Add Divider to preview' })).toBeNull(); + expect(screen.queryByRole('button', { name: 'Rich Text' })).toBeNull(); + expect(screen.getByRole('button', { name: 'Advanced block palette' })).toBeTruthy(); +}); + +it('adds the basic block from simple mode', () => { + const onChange = vi.fn(); + render(); + + fireEvent.click(screen.getByRole('button', { name: 'Add Rich Text Section to preview' })); + expect(latest(onChange)).toHaveLength(1); + expect(latest(onChange)[0].type).toBe('rich_text'); +}); + +it('swaps in the full palette from the Advanced link, and back again', () => { + render(); + + fireEvent.click(screen.getByRole('button', { name: 'Advanced block palette' })); + expect(screen.getByRole('searchbox')).toBeTruthy(); + expect(screen.getByRole('button', { name: 'Add Divider to preview' })).toBeTruthy(); + + // The link becomes the way back, so simple mode isn't a one-way door. + fireEvent.click(screen.getByRole('button', { name: 'Basic block palette' })); + expect(screen.queryByRole('searchbox')).toBeNull(); + expect(screen.queryByRole('button', { name: 'Add Divider to preview' })).toBeNull(); + expect(screen.getByRole('button', { name: 'Add Rich Text Section to preview' })).toBeTruthy(); +}); + +it('drops a stale search query when leaving advanced mode', () => { + render(); + + fireEvent.click(screen.getByRole('button', { name: 'Advanced block palette' })); + fireEvent.change(screen.getByRole('searchbox'), { target: { value: 'divider' } }); + expect(screen.queryByRole('button', { name: 'Add Image to preview' })).toBeNull(); + + fireEvent.click(screen.getByRole('button', { name: 'Basic block palette' })); + fireEvent.click(screen.getByRole('button', { name: 'Advanced block palette' })); + expect((screen.getByRole('searchbox') as HTMLInputElement).value).toBe(''); + expect(screen.getByRole('button', { name: 'Add Divider to preview' })).toBeTruthy(); +}); + +it('stays advanced when the palette flags no basic variants', () => { + // A fully custom palette has no `basic` opt-ins, so simple mode has + // nothing to show — better to render everything than an empty rail. + const CUSTOM: readonly PaletteSection[] = [ + { + name: 'Company presets', + icon: AlignLeft, + variants: [ + { + id: 'help_footer', + label: 'Help footer', + factory: () => ({ type: 'section', text: { type: 'mrkdwn', text: 'Need help?' } }) + } + ] + } + ]; + render(); + + expect(screen.getByRole('button', { name: 'Add Help footer to preview' })).toBeTruthy(); + expect(screen.getByRole('searchbox')).toBeTruthy(); + expect(screen.queryByRole('button', { name: 'Advanced block palette' })).toBeNull(); +}); + +it('lets a custom palette pick its own basic variants', () => { + const CUSTOM: readonly PaletteSection[] = [ + { + name: 'Company presets', + icon: AlignLeft, + variants: [ + { + id: 'help_footer', + label: 'Help footer', + basic: true, + factory: () => ({ type: 'section', text: { type: 'mrkdwn', text: 'Need help?' } }) + }, + { + id: 'company_divider', + label: 'Company divider', + factory: () => ({ type: 'divider' }) + } + ] + } + ]; + render(); + + expect(screen.getByRole('button', { name: 'Add Help footer to preview' })).toBeTruthy(); + expect(screen.queryByRole('button', { name: 'Add Company divider to preview' })).toBeNull(); + expect(screen.getByRole('button', { name: 'Advanced block palette' })).toBeTruthy(); +}); From 5106e92fecd3b3eff5a0f391b9c41ab37a18bf1d Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 15 Aug 2026 17:03:58 +0000 Subject: [PATCH 2/4] feat(demo): palette-mode + editing-mode selectors, always-on Sample Data MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add a "Palette" header selector so the playground can switch the builder between advanced (package default) and simple palette modes — the feature was invisible in the demo, which passes no `paletteMode`. - Reset the palette's expansion and search when a host flips `paletteMode` at runtime, so the new mode starts from the top instead of inheriting the old one's state (adjusted during render, not in an effect). - Turn the demo's edit-mode menu into a plain "Mode" dropdown beside the other header selectors, and move its contents into a "Sample Data" dialog reachable in either mode — the message store is what a reader needs in order to choose a mode, so it shouldn't be behind one. In write-only mode the dialog says what that mode does with the store. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01UDKJELLHkb1HryHes5J843 --- demo/src/App.tsx | 251 +++++++++++++++++------------------- src/components/palette.tsx | 12 ++ test/palette-modes.test.tsx | 17 +++ 3 files changed, 150 insertions(+), 130 deletions(-) diff --git a/demo/src/App.tsx b/demo/src/App.tsx index b1775ed..8aac8f4 100644 --- a/demo/src/App.tsx +++ b/demo/src/App.tsx @@ -4,6 +4,7 @@ import { type ChannelOption, type LoadMessageInput, type LoadResult, + type PaletteMode, type RecentMessage, type SendAsUserStatus, type SendPayload, @@ -34,6 +35,18 @@ const PRESET_OPTIONS: { value: BrandPreset; label: string }[] = [ { value: 'cyberpunk', label: 'Cyberpunk' } ]; +type EditingMode = 'write-only' | 'read-write'; + +const EDITING_MODE_OPTIONS: { value: EditingMode; label: string }[] = [ + { value: 'write-only', label: 'Write-only' }, + { value: 'read-write', label: 'Read & Write' } +]; + +const PALETTE_MODE_OPTIONS: { value: PaletteMode; label: string }[] = [ + { value: 'advanced', label: 'Advanced' }, + { value: 'simple', label: 'Simple' } +]; + const MOCK_CHANNELS: ChannelOption[] = [ { id: 'C0001', name: 'general' }, { id: 'C0002', name: 'random' }, @@ -473,6 +486,9 @@ const AUTO_COLLAPSE_BELOW = 960; export function App() { const [theme, setTheme] = useState<'light' | 'dark'>('light'); const [preset, setPreset] = useState('default'); + // Which palette the builder opens on. 'advanced' is the package default — + // 'simple' starts on the one-block list behind an "Advanced" link. + const [paletteMode, setPaletteMode] = useState('advanced'); // Mirror `theme` onto so the .dark CSS-variable rule reaches // Radix portals (sheets, dialogs, popovers, tooltips). They mount @@ -773,15 +789,28 @@ export function App() {
- setEditingEnabled(v === 'read-write')} + /> + + = { * makes the load → update round-trip observable: copy a message's link, load * it in the builder, update, and watch the blocks change. */ -function EditingMenu({ +/** + * The demo's mock backend, on show: the user-token switches the send/edit + * dialogs read, and the message store "Find message" resolves links against. + * + * Reachable in either editing mode — the store is what a reader needs in + * order to *choose* a mode, so hiding it behind Read & Write meant you had + * to already be in the mode to see what it was for. Write-only just gets a + * line saying the load path is off. + */ +function SampleDataDialog({ editingEnabled, - onEditingEnabledChange, canSendAsUser, onCanSendAsUserChange, includeOauthUrl, @@ -978,7 +1016,6 @@ function EditingMenu({ store }: { editingEnabled: boolean; - onEditingEnabledChange: (v: boolean) => void; canSendAsUser: boolean; onCanSendAsUserChange: (v: boolean) => void; includeOauthUrl: boolean; @@ -1003,31 +1040,6 @@ function EditingMenu({ window.setTimeout(() => setCopiedTs((cur) => (cur === msg.ts ? null : cur)), 1200); }; - // Segmented switcher: a muted track with the active segment raised on a - // solid background, so it reads as a button-style mode toggle. - const tab = (label: string, active: boolean, onSelect: () => void) => ( - - ); - const checkbox = (label: string, checked: boolean, onChange: (v: boolean) => void, disabled?: boolean) => (