From d728622edca0b325a70040c9d9a2ee90f15adf9f Mon Sep 17 00:00:00 2001 From: Zach Hawtof Date: Fri, 15 May 2026 20:12:26 -0400 Subject: [PATCH 1/3] docs(stories): add Storybook catalog for SlackBlockPreview Adds one story per supported block type (header, section, divider, context, actions, image, markdown, rich_text, input, card, carousel, context_actions, alert, table), plus a DarkTheme variant and a WithDirectiveHooks demo. Provides a visual catalog of every block type the preview renders, with a live theme toggle in the controls panel. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../preview/slack-block-preview.stories.tsx | 334 ++++++++++++++++++ 1 file changed, 334 insertions(+) create mode 100644 src/components/preview/slack-block-preview.stories.tsx diff --git a/src/components/preview/slack-block-preview.stories.tsx b/src/components/preview/slack-block-preview.stories.tsx new file mode 100644 index 0000000..16b0226 --- /dev/null +++ b/src/components/preview/slack-block-preview.stories.tsx @@ -0,0 +1,334 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; +import type { SupportedBlock } from '../../types'; +import { SlackBlockPreview } from './slack-block-preview'; + +const meta = { + title: 'BlockKitBuilder/SlackBlockPreview', + component: SlackBlockPreview, + parameters: { layout: 'centered' }, + argTypes: { + theme: { + control: 'inline-radio', + options: ['light', 'dark'] + }, + block: { control: { type: 'object' } } + }, + args: { theme: 'light' }, + decorators: [ + (Story, { args }) => { + const isDark = args.theme === 'dark'; + return ( +
+ +
+ ); + } + ] +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +// Helper preserves discriminated-union narrowing on each fixture. +const block = (b: T): T => b; + +export const Header: Story = { + args: { + block: block({ + type: 'header', + text: { type: 'plain_text', text: 'Welcome to Block Kit', emoji: true } + }) + } +}; + +export const Section: Story = { + args: { + block: block({ + type: 'section', + text: { + type: 'mrkdwn', + text: 'A *markdown* section. Supports _italic_, ~strike~, `code`, and .' + } + }) + } +}; + +export const SectionWithButtonAccessory: Story = { + args: { + block: block({ + type: 'section', + text: { type: 'mrkdwn', text: 'A section with a button accessory on the right.' }, + accessory: { + type: 'button', + text: { type: 'plain_text', text: 'Click me', emoji: true }, + action_id: 'section_button' + } + }) + } +}; + +export const SectionWithImageAccessory: Story = { + args: { + block: block({ + type: 'section', + text: { type: 'mrkdwn', text: 'A section with an image accessory on the right.' }, + accessory: { + type: 'image', + image_url: 'https://placehold.co/80x80?text=Img', + alt_text: 'Placeholder accessory image' + } + }) + } +}; + +export const Divider: Story = { + args: { block: block({ type: 'divider' }) } +}; + +export const Context: Story = { + args: { + block: block({ + type: 'context', + elements: [ + { type: 'image', image_url: 'https://placehold.co/40x40?text=A', alt_text: 'Avatar' }, + { type: 'mrkdwn', text: '*Alex* posted in <#general>' } + ] + }) + } +}; + +export const Actions: Story = { + args: { + block: block({ + type: 'actions', + elements: [ + { + type: 'button', + text: { type: 'plain_text', text: 'Approve', emoji: true }, + style: 'primary', + action_id: 'approve' + }, + { + type: 'button', + text: { type: 'plain_text', text: 'Deny', emoji: true }, + style: 'danger', + action_id: 'deny' + }, + { + type: 'button', + text: { type: 'plain_text', text: 'Cancel', emoji: true }, + action_id: 'cancel' + } + ] + }) + } +}; + +export const Image: Story = { + args: { + block: block({ + type: 'image', + image_url: 'https://placehold.co/600x300?text=Image', + alt_text: 'Placeholder image', + title: { type: 'plain_text', text: 'Image title', emoji: true } + }) + } +}; + +export const Markdown: Story = { + args: { + block: block({ + type: 'markdown', + text: '**Roadmap**\n\n- Item one\n- Item two\n- Item three\n\n`inline code` and [a link](https://slack.com).' + }) + } +}; + +export const RichText: Story = { + args: { + block: block({ + type: 'rich_text', + elements: [ + { + type: 'rich_text_section', + elements: [ + { type: 'text', text: 'A rich text ' }, + { type: 'text', text: 'section', style: { bold: true } }, + { type: 'text', text: ' with inline ' }, + { type: 'text', text: 'styled', style: { italic: true } }, + { type: 'text', text: ' text.' } + ] + } + ] + }) + } +}; + +export const Input: Story = { + args: { + block: block({ + type: 'input', + label: { type: 'plain_text', text: 'Email address', emoji: true }, + element: { + type: 'email_text_input', + action_id: 'email_text_input', + placeholder: { type: 'plain_text', text: 'name@example.com', emoji: true } + } + }) + } +}; + +export const Card: Story = { + args: { + block: block({ + type: 'card', + hero_image: { + type: 'image', + image_url: 'https://placehold.co/400x200?text=Hero', + alt_text: 'Card hero image' + }, + title: { type: 'mrkdwn', text: 'Card title' }, + body: { type: 'mrkdwn', text: 'A short description of what this card is about.' }, + actions: [ + { + type: 'button', + text: { type: 'plain_text', text: 'Open', emoji: true }, + action_id: 'card_action_1' + } + ] + }) + } +}; + +export const Carousel: Story = { + args: { + block: block({ + type: 'carousel', + elements: [ + { + type: 'card', + title: { type: 'mrkdwn', text: 'Card 1' }, + body: { type: 'mrkdwn', text: 'First card in the carousel.' } + }, + { + type: 'card', + title: { type: 'mrkdwn', text: 'Card 2' }, + body: { type: 'mrkdwn', text: 'Second card in the carousel.' } + }, + { + type: 'card', + title: { type: 'mrkdwn', text: 'Card 3' }, + body: { type: 'mrkdwn', text: 'Third card in the carousel.' } + } + ] + }) + } +}; + +export const ContextActions: Story = { + args: { + block: block({ + type: 'context_actions', + elements: [ + { + type: 'feedback_buttons', + action_id: 'feedback', + positive_button: { + text: { type: 'plain_text', text: 'Good Response' }, + value: 'positive' + }, + negative_button: { + text: { type: 'plain_text', text: 'Bad Response' }, + value: 'negative' + } + }, + { + type: 'icon_button', + action_id: 'remove', + icon: 'trash', + text: { type: 'plain_text', text: 'Remove' } + } + ] + }) + } +}; + +export const Alert: Story = { + args: { + block: block({ + type: 'alert', + level: 'warning', + text: { type: 'mrkdwn', text: 'Heads up: this action cannot be undone.' } + }) + } +}; + +export const Table: Story = { + args: { + block: block({ + type: 'table', + rows: [ + [ + { type: 'raw_text', text: 'Header 1' }, + { type: 'raw_text', text: 'Header 2' }, + { type: 'raw_text', text: 'Header 3' } + ], + [ + { type: 'raw_text', text: 'Row 1, A' }, + { type: 'raw_text', text: 'Row 1, B' }, + { type: 'raw_text', text: 'Row 1, C' } + ], + [ + { type: 'raw_text', text: 'Row 2, A' }, + { type: 'raw_text', text: 'Row 2, B' }, + { type: 'raw_text', text: 'Row 2, C' } + ] + ] + }) + } +}; + +export const DarkTheme: Story = { + args: { + theme: 'dark', + block: block({ + type: 'section', + text: { + type: 'mrkdwn', + text: 'Dark theme preview. Same block, `data-theme="dark"` on the wrapper.' + }, + accessory: { + type: 'button', + text: { type: 'plain_text', text: 'Primary', emoji: true }, + style: 'primary', + action_id: 'dark_button' + } + }) + } +}; + +export const WithDirectiveHooks: Story = { + args: { + block: block({ + type: 'section', + text: { + type: 'mrkdwn', + text: 'Hello <@U123>, please review <#C456>. :tada:' + } + }), + hooks: { + user: ({ id }: { id: string }) => `@user-${id}`, + channel: ({ id }: { id: string }) => `#channel-${id}`, + emoji: ({ name }: { name: string }) => `:${name}:` + } + } +}; From f50f5ac1dbf2f8af9bed67f715f7ecc592a045c2 Mon Sep 17 00:00:00 2001 From: Zach Hawtof Date: Fri, 15 May 2026 20:19:36 -0400 Subject: [PATCH 2/3] fix(stories): mark SlackBlockPreview a11y violations as todo The Markdown link, Carousel scrollable region, and DarkTheme button trip axe rules that live in the upstream slack-blocks-to-jsx markup and can't be fixed from the consumer side. Match the rest of the story suite by setting `a11y: { test: 'todo' }` on the meta so axe still reports the violations in the addon panel without failing the vitest storybook project. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/components/preview/slack-block-preview.stories.tsx | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/components/preview/slack-block-preview.stories.tsx b/src/components/preview/slack-block-preview.stories.tsx index 16b0226..32fc032 100644 --- a/src/components/preview/slack-block-preview.stories.tsx +++ b/src/components/preview/slack-block-preview.stories.tsx @@ -5,7 +5,14 @@ import { SlackBlockPreview } from './slack-block-preview'; const meta = { title: 'BlockKitBuilder/SlackBlockPreview', component: SlackBlockPreview, - parameters: { layout: 'centered' }, + parameters: { + layout: 'centered', + // Upstream `slack-blocks-to-jsx` markup trips axe rules we can't fix from + // here (link contrast, carousel keyboard access, dark-mode button + // contrast). Match the rest of the suite and report a11y as todos rather + // than failing the story tests. + a11y: { test: 'todo' } + }, argTypes: { theme: { control: 'inline-radio', From e062e02248d83a6852e54a9aec12b007b2b30da2 Mon Sep 17 00:00:00 2001 From: Zach Hawtof Date: Fri, 15 May 2026 20:31:05 -0400 Subject: [PATCH 3/3] fix(a11y): patch upstream slack-blocks-to-jsx contrast and link styling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds three #slack_blocks_to_jsx-scoped CSS overrides so the preview satisfies axe a11y rules at render time instead of suppressing them at test time: - Inline code: darken from #e01e5a to #c4144b (4.31:1 → 5.9:1 vs the tinted background, hits WCAG AA's 4.5:1). - Markdown links: always-on underline so links don't rely on color alone (WCAG 1.4.1). Upstream only underlines on :hover. - Primary / danger buttons in dark mode: force white text on .bg-green-primary and .bg-red-primary, where upstream applied the light-grey dark-text token (3.52:1 and 3.19:1) on saturated fills. Also drops the now-redundant DarkTheme story (the meta already exposes `theme` as an inline-radio arg, so any story can be toggled) and adds focusable buttons to the Carousel fixture so the scrollable region has keyboard-reachable content. With these fixes, the previous `a11y: { test: 'todo' }` flag is removed and axe runs as a hard check against every story. Verified locally: `vitest run --project storybook` 39/39 passing, axe.run() returns zero violations on Markdown, Carousel, and Actions (dark theme). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../preview/slack-block-preview.stories.tsx | 58 +++++++++---------- src/styles.src.css | 31 ++++++++++ 2 files changed, 59 insertions(+), 30 deletions(-) diff --git a/src/components/preview/slack-block-preview.stories.tsx b/src/components/preview/slack-block-preview.stories.tsx index 32fc032..5b3c71c 100644 --- a/src/components/preview/slack-block-preview.stories.tsx +++ b/src/components/preview/slack-block-preview.stories.tsx @@ -5,14 +5,7 @@ import { SlackBlockPreview } from './slack-block-preview'; const meta = { title: 'BlockKitBuilder/SlackBlockPreview', component: SlackBlockPreview, - parameters: { - layout: 'centered', - // Upstream `slack-blocks-to-jsx` markup trips axe rules we can't fix from - // here (link contrast, carousel keyboard access, dark-mode button - // contrast). Match the rest of the suite and report a11y as todos rather - // than failing the story tests. - a11y: { test: 'todo' } - }, + parameters: { layout: 'centered' }, argTypes: { theme: { control: 'inline-radio', @@ -218,23 +211,47 @@ export const Card: Story = { export const Carousel: Story = { args: { + // Each card carries a button so the scrollable carousel container has + // focusable descendants, which satisfies axe's + // `scrollable-region-focusable` rule. block: block({ type: 'carousel', elements: [ { type: 'card', title: { type: 'mrkdwn', text: 'Card 1' }, - body: { type: 'mrkdwn', text: 'First card in the carousel.' } + body: { type: 'mrkdwn', text: 'First card in the carousel.' }, + actions: [ + { + type: 'button', + text: { type: 'plain_text', text: 'Open 1', emoji: true }, + action_id: 'open_1' + } + ] }, { type: 'card', title: { type: 'mrkdwn', text: 'Card 2' }, - body: { type: 'mrkdwn', text: 'Second card in the carousel.' } + body: { type: 'mrkdwn', text: 'Second card in the carousel.' }, + actions: [ + { + type: 'button', + text: { type: 'plain_text', text: 'Open 2', emoji: true }, + action_id: 'open_2' + } + ] }, { type: 'card', title: { type: 'mrkdwn', text: 'Card 3' }, - body: { type: 'mrkdwn', text: 'Third card in the carousel.' } + body: { type: 'mrkdwn', text: 'Third card in the carousel.' }, + actions: [ + { + type: 'button', + text: { type: 'plain_text', text: 'Open 3', emoji: true }, + action_id: 'open_3' + } + ] } ] }) @@ -304,25 +321,6 @@ export const Table: Story = { } }; -export const DarkTheme: Story = { - args: { - theme: 'dark', - block: block({ - type: 'section', - text: { - type: 'mrkdwn', - text: 'Dark theme preview. Same block, `data-theme="dark"` on the wrapper.' - }, - accessory: { - type: 'button', - text: { type: 'plain_text', text: 'Primary', emoji: true }, - style: 'primary', - action_id: 'dark_button' - } - }) - } -}; - export const WithDirectiveHooks: Story = { args: { block: block({ diff --git a/src/styles.src.css b/src/styles.src.css index 68b6f9d..01c827a 100644 --- a/src/styles.src.css +++ b/src/styles.src.css @@ -61,6 +61,37 @@ --radius-sm: calc(var(--radius, 0.5rem) - 4px); } +/* Accessibility overrides for upstream slack-blocks-to-jsx markup. + All selectors are scoped under #slack_blocks_to_jsx so they apply only + inside the preview pane and don't leak into consumer styles. + + - Inline code: upstream uses rgb(224 30 90) on a tinted bg, giving 4.31:1 + contrast (fails WCAG AA's 4.5:1 for normal text). Darken the foreground. + - Links: upstream only underlines on :hover, leaving color as the sole + visual cue at rest. Add a permanent underline so links remain + distinguishable per WCAG 1.4.1 ("Use of color"), with no reliance on + the 2.73:1 link-vs-body contrast. + - Filled-style buttons in dark mode: upstream applies the dark text + token (rgb 209 210 211) on .bg-green-primary (primary) and + .bg-red-primary (danger), yielding 3.52:1 and 3.19:1 respectively. + Force white so both hit 4.5:1+ in both themes. */ +#slack_blocks_to_jsx code.slack_code_inline, +#slack_blocks_to_jsx pre code.text-red-primary { + color: #c4144b; +} + +#slack_blocks_to_jsx a.text-blue-primary { + text-decoration: underline; + text-underline-offset: 2px; +} + +#slack_blocks_to_jsx .bg-green-primary, +#slack_blocks_to_jsx .bg-green-primary *, +#slack_blocks_to_jsx .bg-red-primary, +#slack_blocks_to_jsx .bg-red-primary * { + color: #ffffff; +} + /* TipTap rich text editor: nested-list marker cycle that matches slack-blocks-to-jsx's preview rendering (decimal → lower-alpha → lower-roman, repeating every three levels up to Slack's max indent