@component-anatomy/storybook 0.0.2 → 0.2.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.
@@ -0,0 +1,238 @@
1
+ /**
2
+ * The anatomy part table — pure presentation, no Storybook imports.
3
+ *
4
+ * This module is deliberately free of `storybook/manager-api` and
5
+ * `storybook/preview-api`: it is bundled into *both* the manager entry (the
6
+ * addon panel) and the blocks entry (the `<Anatomy>` MDX doc block), which
7
+ * run in different runtimes and cannot share either of those APIs. Only the
8
+ * data acquisition differs between the two; the markup must not.
9
+ */
10
+ import React from 'react';
11
+ import { useTheme } from 'storybook/theming';
12
+ import { resolvePanelAccent } from '@component-anatomy/core';
13
+ import type {
14
+ AnatomyPartDefinition,
15
+ AnatomyPresetName,
16
+ AnatomyTheme,
17
+ } from '@component-anatomy/core';
18
+
19
+ /**
20
+ * The subset of Storybook's theme this table reads. Typed loosely on purpose:
21
+ * `useTheme()` resolves to an empty `Theme` interface (Emotion augmentation),
22
+ * and outside a ThemeProvider it returns `{}` — so every read is optional and
23
+ * every value has a literal fallback below.
24
+ */
25
+ type PartialStorybookTheme = {
26
+ fgColor?: { default?: string; muted?: string };
27
+ bgColor?: { default?: string; muted?: string };
28
+ borderColor?: { default?: string };
29
+ typography?: { fonts?: { base?: string; mono?: string } };
30
+ };
31
+
32
+ const FONT_BASE_FALLBACK =
33
+ '"Nunito Sans", -apple-system, ".SFNSText-Regular", "San Francisco", BlinkMacSystemFont, "Segoe UI", "Helvetica Neue", Helvetica, Arial, sans-serif';
34
+ const FONT_MONO_FALLBACK = 'ui-monospace, "Cascadia Code", "Fira Mono", monospace';
35
+
36
+ /**
37
+ * Resolves the table's chrome colors from Storybook's theme so the same
38
+ * markup reads correctly in the manager panel *and* in a docs page under a
39
+ * dark theme.
40
+ *
41
+ * `surfaceBg`/`surfaceFg` are not painted anywhere — they are what the accent
42
+ * is measured against in {@link useAccent}. They carry Storybook's own light
43
+ * defaults as fallbacks so the measurement stays meaningful outside a
44
+ * ThemeProvider, where `useTheme()` returns `{}`.
45
+ */
46
+ function usePalette() {
47
+ const theme = useTheme() as PartialStorybookTheme;
48
+ return {
49
+ text: theme.fgColor?.default,
50
+ muted: theme.fgColor?.muted ?? '#6b7280',
51
+ mutedBg: theme.bgColor?.muted ?? 'rgba(0,0,0,0.06)',
52
+ border: theme.borderColor?.default ?? 'rgba(0,0,0,0.08)',
53
+ surfaceBg: theme.bgColor?.default ?? '#ffffff',
54
+ surfaceFg: theme.fgColor?.default ?? '#2e3438',
55
+ fontBase: theme.typography?.fonts?.base ?? FONT_BASE_FALLBACK,
56
+ fontMono: theme.typography?.fonts?.mono ?? FONT_MONO_FALLBACK,
57
+ };
58
+ }
59
+
60
+ type Palette = ReturnType<typeof usePalette>;
61
+
62
+ /**
63
+ * The accent for the active row — the story's own `{ preset, theme }`, so the
64
+ * table agrees with the overlays it describes, lifted to WCAG AA against the
65
+ * surface it is painted on when the two disagree.
66
+ *
67
+ * The check is not optional polish: `preset: 'contrast'` is black on yellow
68
+ * and the Storybook manager is dark by default, so the preset that exists for
69
+ * users who need contrast is exactly the one that would land at 1.3:1 here.
70
+ */
71
+ function useAccent(palette: Palette, preset?: AnatomyPresetName, theme?: AnatomyTheme): string {
72
+ return resolvePanelAccent(preset, theme, {
73
+ background: palette.surfaceBg,
74
+ foreground: palette.surfaceFg,
75
+ });
76
+ }
77
+
78
+ const makeStyles = (p: Palette): Record<string, React.CSSProperties> => ({
79
+ container: {
80
+ padding: '12px 16px',
81
+ fontFamily: p.fontBase,
82
+ fontSize: 13,
83
+ lineHeight: 1.5,
84
+ color: p.text,
85
+ },
86
+ empty: {
87
+ color: p.muted,
88
+ margin: 0,
89
+ },
90
+ code: {
91
+ fontFamily: p.fontMono,
92
+ fontSize: '0.85em',
93
+ background: p.mutedBg,
94
+ borderRadius: 3,
95
+ padding: '1px 5px',
96
+ },
97
+ list: {
98
+ display: 'flex',
99
+ flexDirection: 'column',
100
+ gap: 2,
101
+ margin: 0,
102
+ padding: 0,
103
+ listStyle: 'none',
104
+ },
105
+ entry: {
106
+ padding: '8px 10px',
107
+ borderRadius: 6,
108
+ border: '1px solid transparent',
109
+ cursor: 'default',
110
+ outline: 'none',
111
+ transition: 'background 120ms ease, border-color 120ms ease',
112
+ },
113
+ header: {
114
+ display: 'flex',
115
+ alignItems: 'center',
116
+ gap: 8,
117
+ },
118
+ indicator: {
119
+ width: 8,
120
+ height: 8,
121
+ borderRadius: '50%',
122
+ border: `2px solid ${p.border}`,
123
+ flexShrink: 0,
124
+ transition: 'background 120ms ease, border-color 120ms ease',
125
+ },
126
+ name: {
127
+ fontWeight: 700,
128
+ flex: 1,
129
+ },
130
+ id: {
131
+ fontFamily: p.fontMono,
132
+ fontSize: 10,
133
+ padding: '1px 6px',
134
+ borderRadius: 4,
135
+ background: p.mutedBg,
136
+ border: `1px solid ${p.border}`,
137
+ color: p.muted,
138
+ flexShrink: 0,
139
+ },
140
+ description: {
141
+ margin: '4px 0 0',
142
+ paddingLeft: 16,
143
+ color: p.muted,
144
+ },
145
+ });
146
+
147
+ /**
148
+ * An inline `<code>` styled for the surrounding message text. Exported so
149
+ * both runtimes can compose their own empty-state copy without duplicating
150
+ * the style object.
151
+ */
152
+ export const AnatomyCode: React.FC<{ children: React.ReactNode }> = ({ children }) => {
153
+ const styles = makeStyles(usePalette());
154
+ return <code style={styles.code}>{children}</code>;
155
+ };
156
+
157
+ /** A padded, muted paragraph — used for every "nothing to show" state. */
158
+ export const AnatomyMessage: React.FC<{ children: React.ReactNode }> = ({ children }) => {
159
+ const styles = makeStyles(usePalette());
160
+ return (
161
+ <div style={styles.container}>
162
+ <p style={styles.empty}>{children}</p>
163
+ </div>
164
+ );
165
+ };
166
+
167
+ export type AnatomyTableProps = {
168
+ /** Parts to list, in the order they should be shown. */
169
+ parts: AnatomyPartDefinition[];
170
+ /** Id of the part currently highlighted in the canvas, if any. */
171
+ activeId?: string | null;
172
+ /** The story's `anatomy.preset` — the active row follows it. */
173
+ preset?: AnatomyPresetName;
174
+ /** The story's `anatomy.theme` — token overrides on top of the preset. */
175
+ theme?: AnatomyTheme;
176
+ /** Called when the user hovers or focuses an entry. */
177
+ onItemEnter?: (partId: string) => void;
178
+ /** Called when the user leaves or blurs an entry. */
179
+ onItemLeave?: () => void;
180
+ };
181
+
182
+ export const AnatomyTable: React.FC<AnatomyTableProps> = ({
183
+ parts,
184
+ activeId = null,
185
+ preset,
186
+ theme,
187
+ onItemEnter,
188
+ onItemLeave,
189
+ }) => {
190
+ const palette = usePalette();
191
+ const styles = makeStyles(palette);
192
+ const accent = useAccent(palette, preset, theme);
193
+
194
+ return (
195
+ <div style={styles.container}>
196
+ <ul style={styles.list} role="list" aria-label="Anatomy parts">
197
+ {parts.map((part) => {
198
+ const active = activeId === part.id;
199
+ return (
200
+ <li
201
+ key={part.id}
202
+ role="listitem"
203
+ tabIndex={0}
204
+ aria-label={part.name}
205
+ style={{
206
+ ...styles.entry,
207
+ background: active ? `color-mix(in srgb, ${accent} 7%, transparent)` : undefined,
208
+ borderColor: active
209
+ ? `color-mix(in srgb, ${accent} 20%, transparent)`
210
+ : 'transparent',
211
+ }}
212
+ onMouseEnter={() => onItemEnter?.(part.id)}
213
+ onMouseLeave={() => onItemLeave?.()}
214
+ onFocus={() => onItemEnter?.(part.id)}
215
+ onBlur={() => onItemLeave?.()}
216
+ >
217
+ <div style={styles.header}>
218
+ <span
219
+ aria-hidden="true"
220
+ style={{
221
+ ...styles.indicator,
222
+ background: active ? accent : undefined,
223
+ borderColor: active ? accent : palette.border,
224
+ }}
225
+ />
226
+ <span style={{ ...styles.name, color: active ? accent : undefined }}>
227
+ {part.name}
228
+ </span>
229
+ <code style={styles.id}>{part.id}</code>
230
+ </div>
231
+ {part.description && <p style={styles.description}>{part.description}</p>}
232
+ </li>
233
+ );
234
+ })}
235
+ </ul>
236
+ </div>
237
+ );
238
+ };
package/src/Panel.tsx CHANGED
@@ -3,78 +3,11 @@ import { useChannel, useParameter, useStorybookApi } from 'storybook/manager-api
3
3
  import type { AnatomyPartDefinition } from '@component-anatomy/core';
4
4
 
5
5
  import { EVENTS, PARAM_KEY } from './constants.js';
6
+ import { matchesStory } from './channel.js';
7
+ import type { PartEnterEvent, PartsEvent, StoryScopedEvent } from './channel.js';
8
+ import { AnatomyCode, AnatomyMessage, AnatomyTable } from './AnatomyTable.js';
6
9
  import type { AnatomyParameters } from './types.js';
7
10
 
8
- const ACCENT_FALLBACK = '#4f46e5';
9
-
10
- const styles: Record<string, React.CSSProperties> = {
11
- container: {
12
- padding: '12px 16px',
13
- fontFamily:
14
- '"Nunito Sans", -apple-system, ".SFNSText-Regular", "San Francisco", BlinkMacSystemFont, "Segoe UI", "Helvetica Neue", Helvetica, Arial, sans-serif',
15
- fontSize: 13,
16
- lineHeight: 1.5,
17
- },
18
- empty: {
19
- color: '#6b7280',
20
- },
21
- code: {
22
- fontFamily: 'ui-monospace, "Cascadia Code", "Fira Mono", monospace',
23
- fontSize: '0.85em',
24
- background: 'rgba(0,0,0,0.06)',
25
- borderRadius: 3,
26
- padding: '1px 5px',
27
- },
28
- list: {
29
- display: 'flex',
30
- flexDirection: 'column',
31
- gap: 2,
32
- margin: 0,
33
- padding: 0,
34
- listStyle: 'none',
35
- },
36
- entry: {
37
- padding: '8px 10px',
38
- borderRadius: 6,
39
- border: '1px solid transparent',
40
- cursor: 'default',
41
- outline: 'none',
42
- transition: 'background 120ms ease, border-color 120ms ease',
43
- },
44
- header: {
45
- display: 'flex',
46
- alignItems: 'center',
47
- gap: 8,
48
- },
49
- indicator: {
50
- width: 8,
51
- height: 8,
52
- borderRadius: '50%',
53
- border: '2px solid #d1d5db',
54
- flexShrink: 0,
55
- transition: 'background 120ms ease, border-color 120ms ease',
56
- },
57
- name: {
58
- fontWeight: 700,
59
- flex: 1,
60
- },
61
- id: {
62
- fontFamily: 'ui-monospace, "Cascadia Code", "Fira Mono", monospace',
63
- fontSize: 10,
64
- padding: '1px 6px',
65
- borderRadius: 4,
66
- background: 'rgba(0,0,0,0.05)',
67
- border: '1px solid rgba(0,0,0,0.08)',
68
- color: '#6b7280',
69
- flexShrink: 0,
70
- },
71
- description: {
72
- margin: '4px 0 0',
73
- paddingLeft: 16,
74
- color: '#6b7280',
75
- },
76
- };
77
-
78
11
  export const Panel: React.FC = () => {
79
12
  const params = useParameter<AnatomyParameters | undefined>(PARAM_KEY, undefined);
80
13
  const api = useStorybookApi();
@@ -83,86 +16,74 @@ export const Panel: React.FC = () => {
83
16
  const [discovered, setDiscovered] = useState<AnatomyPartDefinition[]>([]);
84
17
  const [activeId, setActiveId] = useState<string | null>(null);
85
18
 
86
- const emit = useChannel({
87
- [EVENTS.PARTS]: ({ parts }: { parts: AnatomyPartDefinition[] }) => setDiscovered(parts),
88
- [EVENTS.PART_ENTER]: ({ partId }: { partId: string }) => setActiveId(partId),
89
- [EVENTS.PART_LEAVE]: () => setActiveId(null),
90
- });
19
+ // `useChannel` captures its handlers on the given deps — `storyId` has to be
20
+ // listed or the filters below would close over a stale story.
21
+ const emit = useChannel(
22
+ {
23
+ [EVENTS.PARTS]: (event: PartsEvent) => {
24
+ if (!matchesStory(event?.storyId, storyId)) return;
25
+ setDiscovered(event.parts);
26
+ },
27
+ [EVENTS.PART_ENTER]: (event: PartEnterEvent) => {
28
+ if (!matchesStory(event?.storyId, storyId)) return;
29
+ setActiveId(event.partId);
30
+ },
31
+ [EVENTS.PART_LEAVE]: (event: StoryScopedEvent = {}) => {
32
+ if (!matchesStory(event?.storyId, storyId)) return;
33
+ setActiveId(null);
34
+ },
35
+ },
36
+ [storyId]
37
+ );
91
38
 
92
39
  // Ask the preview for the current part list on mount / story change.
93
40
  useEffect(() => {
94
41
  setDiscovered([]);
95
42
  setActiveId(null);
96
- emit(EVENTS.PARTS_REQUEST);
43
+ emit(EVENTS.PARTS_REQUEST, { storyId } satisfies StoryScopedEvent);
97
44
  }, [storyId]);
98
45
 
99
46
  const parts = params?.parts ?? discovered;
100
- const accent = params?.theme?.accent ?? ACCENT_FALLBACK;
101
47
 
102
- if (!params || params.disable) {
48
+ if (!params) {
49
+ return (
50
+ <AnatomyMessage>
51
+ No anatomy configured for this story. Add <AnatomyCode>parameters.anatomy</AnatomyCode> and
52
+ annotate elements with <AnatomyCode>data-part="name"</AnatomyCode>.
53
+ </AnatomyMessage>
54
+ );
55
+ }
56
+
57
+ // Defence in depth: Storybook removes a panel from the tab bar entirely when
58
+ // `parameters[paramKey].disable` is set, so this normally never renders. If
59
+ // it ever does, say the right thing — a disabled story *has* an anatomy, and
60
+ // telling its author to add one sends them the wrong way.
61
+ if (params.disable) {
103
62
  return (
104
- <div style={styles.container}>
105
- <p style={styles.empty}>
106
- No anatomy configured for this story. Add{' '}
107
- <code style={styles.code}>parameters.anatomy</code> and annotate elements with{' '}
108
- <code style={styles.code}>data-part="name"</code>.
109
- </p>
110
- </div>
63
+ <AnatomyMessage>
64
+ Anatomy is disabled for this story (<AnatomyCode>anatomy.disable</AnatomyCode>).
65
+ </AnatomyMessage>
111
66
  );
112
67
  }
113
68
 
114
69
  if (parts.length === 0) {
115
70
  return (
116
- <div style={styles.container}>
117
- <p style={styles.empty}>
118
- No parts found. Annotate elements in your story with{' '}
119
- <code style={styles.code}>data-part="name"</code> or pass{' '}
120
- <code style={styles.code}>parameters.anatomy.parts</code>.
121
- </p>
122
- </div>
71
+ <AnatomyMessage>
72
+ No parts found. Annotate elements in your story with{' '}
73
+ <AnatomyCode>data-part="name"</AnatomyCode> or pass{' '}
74
+ <AnatomyCode>parameters.anatomy.parts</AnatomyCode>.
75
+ </AnatomyMessage>
123
76
  );
124
77
  }
125
78
 
126
79
  return (
127
- <div style={styles.container}>
128
- <ul style={styles.list} role="list" aria-label="Anatomy parts">
129
- {parts.map((part) => {
130
- const active = activeId === part.id;
131
- return (
132
- <li
133
- key={part.id}
134
- role="listitem"
135
- tabIndex={0}
136
- aria-label={part.name}
137
- style={{
138
- ...styles.entry,
139
- background: active ? `color-mix(in srgb, ${accent} 7%, transparent)` : undefined,
140
- borderColor: active ? `color-mix(in srgb, ${accent} 20%, transparent)` : 'transparent',
141
- }}
142
- onMouseEnter={() => emit(EVENTS.HOVER_ITEM, { partId: part.id })}
143
- onMouseLeave={() => emit(EVENTS.LEAVE_ITEM)}
144
- onFocus={() => emit(EVENTS.HOVER_ITEM, { partId: part.id })}
145
- onBlur={() => emit(EVENTS.LEAVE_ITEM)}
146
- >
147
- <div style={styles.header}>
148
- <span
149
- aria-hidden="true"
150
- style={{
151
- ...styles.indicator,
152
- background: active ? accent : undefined,
153
- borderColor: active ? accent : '#d1d5db',
154
- }}
155
- />
156
- <span style={{ ...styles.name, color: active ? accent : undefined }}>
157
- {part.name}
158
- </span>
159
- <code style={styles.id}>{part.id}</code>
160
- </div>
161
- {part.description && <p style={styles.description}>{part.description}</p>}
162
- </li>
163
- );
164
- })}
165
- </ul>
166
- </div>
80
+ <AnatomyTable
81
+ parts={parts}
82
+ activeId={activeId}
83
+ preset={params.preset}
84
+ theme={params.theme}
85
+ onItemEnter={(partId) => emit(EVENTS.HOVER_ITEM, { storyId, partId })}
86
+ onItemLeave={() => emit(EVENTS.LEAVE_ITEM, { storyId })}
87
+ />
167
88
  );
168
89
  };
package/src/blocks.tsx ADDED
@@ -0,0 +1,196 @@
1
+ /**
2
+ * Docs blocks entry — the `<Anatomy>` block for MDX pages.
3
+ *
4
+ * Unlike the addon panel, this runs in the **preview iframe**, where
5
+ * `storybook/manager-api` does not exist. It reaches the story's controller
6
+ * over the addon channel instead: `Channel.emit` dispatches to local
7
+ * listeners as well as across transports, so a block and the decorator that
8
+ * mounted the story talk to each other directly, in-frame, with no extra
9
+ * plumbing.
10
+ *
11
+ * ```mdx
12
+ * import { Meta, Canvas } from '@storybook/addon-docs/blocks';
13
+ * import { Anatomy } from '@component-anatomy/storybook/blocks';
14
+ * import * as ButtonStories from './Button.stories';
15
+ *
16
+ * <Meta of={ButtonStories} />
17
+ *
18
+ * <Canvas of={ButtonStories.Anatomy} />
19
+ * <Anatomy of={ButtonStories.Anatomy} />
20
+ * ```
21
+ */
22
+ import React, { useEffect, useState } from 'react';
23
+ import { addons } from 'storybook/preview-api';
24
+ import { Unstyled, useOf } from '@storybook/addon-docs/blocks';
25
+ import type { Of } from '@storybook/addon-docs/blocks';
26
+ import type { AnatomyPartDefinition } from '@component-anatomy/core';
27
+
28
+ import { EVENTS, PARAM_KEY } from './constants.js';
29
+ import { matchesStory } from './channel.js';
30
+ import type { PartEnterEvent, PartsEvent, StoryScopedEvent } from './channel.js';
31
+ import { AnatomyCode, AnatomyMessage, AnatomyTable } from './AnatomyTable.js';
32
+ import type { AnatomyParameters } from './types.js';
33
+
34
+ export type AnatomyBlockProps = {
35
+ /**
36
+ * The CSF export to document — a story export, or the whole module export
37
+ * of a CSF file to read the meta's parameters.
38
+ *
39
+ * Omit it on an attached docs page (one with `<Meta of={...} />`, or an
40
+ * autodocs page) to fall back to the page's current story, mirroring how
41
+ * the other docs blocks resolve `of`.
42
+ */
43
+ of?: Of;
44
+ /**
45
+ * Part list override. Skips both `parameters.anatomy.parts` and
46
+ * auto-discovery — useful for a hand-curated subset in prose.
47
+ */
48
+ parts?: AnatomyPartDefinition[];
49
+ /**
50
+ * Two-way hover sync with the rendered story. Default: `true`. Set to
51
+ * `false` for a purely static table (also skips auto-discovery, since that
52
+ * arrives over the channel).
53
+ */
54
+ sync?: boolean;
55
+ };
56
+
57
+ /**
58
+ * `addons.getChannel()` throws when no channel is installed. That should not
59
+ * happen inside a rendered docs page, but an MDX page is user-authored
60
+ * content and a throw here would blank the whole page — degrade to a static
61
+ * table instead.
62
+ */
63
+ function getChannelSafely() {
64
+ try {
65
+ return addons.getChannel();
66
+ } catch {
67
+ return null;
68
+ }
69
+ }
70
+
71
+ export const Anatomy: React.FC<AnatomyBlockProps> = ({ of, parts: partsProp, sync = true }) => {
72
+ const resolved = useOf(of ?? 'story', ['story', 'meta']);
73
+
74
+ const params = (
75
+ resolved.type === 'meta'
76
+ ? resolved.preparedMeta.parameters?.[PARAM_KEY]
77
+ : resolved.story.parameters?.[PARAM_KEY]
78
+ ) as AnatomyParameters | undefined;
79
+
80
+ // Only a story has a canvas to sync with. `of={SomeStories}` (a meta)
81
+ // documents the component as a whole and can render static parts only.
82
+ const storyId = resolved.type === 'story' ? resolved.story.id : undefined;
83
+
84
+ const [discovered, setDiscovered] = useState<AnatomyPartDefinition[]>([]);
85
+ const [activeId, setActiveId] = useState<string | null>(null);
86
+
87
+ const staticParts = partsProp ?? params?.parts;
88
+ // Hover sync is wired even when the parts are static — an explicit list
89
+ // still wants the canvas to light up. Only discovery depends on the channel.
90
+ const wired = sync && !!storyId;
91
+
92
+ useEffect(() => {
93
+ setDiscovered([]);
94
+ setActiveId(null);
95
+ if (!wired) return;
96
+
97
+ const channel = getChannelSafely();
98
+ if (!channel) return;
99
+
100
+ const onParts = (event: PartsEvent) => {
101
+ if (!matchesStory(event?.storyId, storyId)) return;
102
+ setDiscovered(event.parts);
103
+ };
104
+ const onEnter = (event: PartEnterEvent) => {
105
+ if (!matchesStory(event?.storyId, storyId)) return;
106
+ setActiveId(event.partId);
107
+ };
108
+ const onLeave = (event: StoryScopedEvent = {}) => {
109
+ if (!matchesStory(event?.storyId, storyId)) return;
110
+ setActiveId(null);
111
+ };
112
+
113
+ channel.on(EVENTS.PARTS, onParts);
114
+ channel.on(EVENTS.PART_ENTER, onEnter);
115
+ channel.on(EVENTS.PART_LEAVE, onLeave);
116
+
117
+ // The block usually mounts before the story below it finishes rendering;
118
+ // the request covers the other order.
119
+ channel.emit(EVENTS.PARTS_REQUEST, { storyId } satisfies StoryScopedEvent);
120
+
121
+ return () => {
122
+ channel.off(EVENTS.PARTS, onParts);
123
+ channel.off(EVENTS.PART_ENTER, onEnter);
124
+ channel.off(EVENTS.PART_LEAVE, onLeave);
125
+ };
126
+ }, [wired, storyId]);
127
+
128
+ const parts = staticParts ?? discovered;
129
+
130
+ const emitHover = (partId: string) => {
131
+ if (!wired) return;
132
+ getChannelSafely()?.emit(EVENTS.HOVER_ITEM, { storyId, partId });
133
+ };
134
+ const emitLeave = () => {
135
+ if (!wired) return;
136
+ getChannelSafely()?.emit(EVENTS.LEAVE_ITEM, { storyId } satisfies StoryScopedEvent);
137
+ };
138
+
139
+ // `Unstyled` keeps the docs page's prose CSS (`.sbdocs` restyles ul/li/p/
140
+ // code) from reaching the table.
141
+ if (!params && !partsProp) {
142
+ return (
143
+ <Unstyled>
144
+ <AnatomyMessage>
145
+ No anatomy configured. Add <AnatomyCode>parameters.anatomy</AnatomyCode> to the story you
146
+ pass to <AnatomyCode>of</AnatomyCode>, or pass a{' '}
147
+ <AnatomyCode>parts</AnatomyCode> list to this block.
148
+ </AnatomyMessage>
149
+ </Unstyled>
150
+ );
151
+ }
152
+
153
+ if (params?.disable && !partsProp) {
154
+ return (
155
+ <Unstyled>
156
+ <AnatomyMessage>
157
+ Anatomy is disabled for this story (<AnatomyCode>anatomy.disable</AnatomyCode>).
158
+ </AnatomyMessage>
159
+ </Unstyled>
160
+ );
161
+ }
162
+
163
+ if (parts.length === 0) {
164
+ return (
165
+ <Unstyled>
166
+ <AnatomyMessage>
167
+ {resolved.type === 'meta' ? (
168
+ <>
169
+ No parts found. A meta has no canvas to discover parts from — pass a story to{' '}
170
+ <AnatomyCode>of</AnatomyCode>, or list <AnatomyCode>parts</AnatomyCode> explicitly.
171
+ </>
172
+ ) : (
173
+ <>
174
+ No parts found. Auto-discovery reads the rendered story, so make sure it is on this
175
+ page (e.g. with a <AnatomyCode>{'<Canvas of={…} />'}</AnatomyCode> block above), or
176
+ list <AnatomyCode>parts</AnatomyCode> explicitly.
177
+ </>
178
+ )}
179
+ </AnatomyMessage>
180
+ </Unstyled>
181
+ );
182
+ }
183
+
184
+ return (
185
+ <Unstyled>
186
+ <AnatomyTable
187
+ parts={parts}
188
+ activeId={activeId}
189
+ preset={params?.preset}
190
+ theme={params?.theme}
191
+ onItemEnter={emitHover}
192
+ onItemLeave={emitLeave}
193
+ />
194
+ </Unstyled>
195
+ );
196
+ };
package/src/channel.ts ADDED
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Shared channel payload contract between the preview decorator, the manager
3
+ * panel, and the MDX doc block.
4
+ *
5
+ * Every payload carries the `storyId` it refers to. In story view this is
6
+ * redundant — only one story is mounted — but a docs page mounts *many*
7
+ * stories at once, each with its own controller, and each `<Anatomy>` block
8
+ * must talk to exactly one of them. Without addressing, hovering a part in
9
+ * one block highlights the matching part in every other story on the page.
10
+ */
11
+ import type { AnatomyPartDefinition } from '@component-anatomy/core';
12
+
13
+ /** preview → consumers: the resolved part list for one story. */
14
+ export type PartsEvent = { storyId?: string; parts: AnatomyPartDefinition[] };
15
+
16
+ /** preview → consumers: a part became active in that story's canvas. */
17
+ export type PartEnterEvent = { storyId?: string; partId: string };
18
+
19
+ /** consumer → preview: highlight this part in that story's canvas. */
20
+ export type HoverItemEvent = { storyId?: string; partId: string };
21
+
22
+ /** Payload for the events that only need to name a story. */
23
+ export type StoryScopedEvent = { storyId?: string };
24
+
25
+ /**
26
+ * Whether an event addressed to `eventStoryId` concerns `storyId`.
27
+ *
28
+ * A missing id on *either* side matches everything. That keeps the protocol
29
+ * backward compatible: a manager panel from a newer build still understands
30
+ * an older preview bundle that emits unaddressed events, and vice versa.
31
+ */
32
+ export const matchesStory = (
33
+ eventStoryId: string | undefined,
34
+ storyId: string | undefined
35
+ ): boolean => !eventStoryId || !storyId || eventStoryId === storyId;